Compare commits

..

46 Commits

Author SHA1 Message Date
Johannes
ca55d6bbc0 fix: upgrade tar-fs to resolve CVE-2024-12905 and CVE-2025-48387
Add pnpm override to force tar-fs>=2.1.4, upgrading from vulnerable
version 2.1.3 to patched version 3.1.1.

Resolves path traversal and symlink attack vulnerabilities in tar-fs
that could allow malicious tarballs to write files outside the intended
extraction directory.

The tar-fs package is only used as a transitive dependency via
prebuild-install (used by sharp) for extracting native binaries during
installation, so this upgrade has no runtime impact on the application.

Fixes: https://github.com/formbricks/formbricks/security/dependabot/205
2025-10-06 13:11:53 +02:00
Matti Nannt
3f122ed9ee perf: reduce cache TTL to 1 minute for SDK environment state and segments (#6635) 2025-10-06 10:12:46 +00:00
Jakob Schott
bdad80d6d1 fix: remove capitalize functions (#6610)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-10-06 10:07:23 +00:00
Johannes
d9ea00d86e fix: allow deselecting optional single-select question responses (#6643)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-06 09:32:24 +00:00
Johannes
4a3c2fccba chore: add Cursor rule for Review & Refinement (#6648) 2025-10-06 01:38:42 -07:00
Johannes
3a09af674a feat: hit ENTER for new option (#6624)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-06 07:23:17 +00:00
Dhruwang Jariwala
1ced76c44d chore: added expirationDays param support in personal link api (#6578)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-06 07:12:29 +00:00
Victor Hugo dos Santos
fa1663d858 docs: enhance file upload troubleshooting guidance in migration (#6645)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-06 06:40:06 +00:00
Victor Hugo dos Santos
ebf591a7e0 fix: improve E2E test reliability and security (#6653) 2025-10-06 05:02:51 +00:00
Dhruwang Jariwala
5c9795cd23 chore: update @boxyhq/saml-jackson and posthog-node (#6647) 2025-10-04 09:26:30 +02:00
Victor Hugo dos Santos
b67177ba55 Merge commit from fork
* fix(auth): enhance password validation and rate limiting for login attempts

- Added password length validation to prevent CPU DoS attacks, limiting to 128 characters.
- Implemented constant-time password verification to mitigate timing attacks.
- Adjusted rate limit for login attempts from 30 to 10 per 15 minutes for improved security.
- Updated login form validation to reflect new password length constraints.
- Introduced constants for authentication endpoints in the API.

* fixed sample size for timing test

* password validation messages

---------

Co-authored-by: Your Name <you@example.com>
2025-10-02 11:09:28 +02:00
Johannes
6cf1f49c8e docs: add tag docs (#6640) 2025-10-02 01:47:31 -07:00
Johannes
4afb95b92a fix: switch Manage Subscription button bg to stripe color (#6633) 2025-10-01 12:00:44 +00:00
Piyush Gupta
38089241b4 chore: adds surveys package readme (#6598) 2025-10-01 11:26:03 +00:00
Johannes
07487d4871 docs: update license pages (#6631) 2025-10-01 01:40:19 -07:00
Johannes
fa0879e3a0 chore: increase visibility of hover effect to indicate clickability (#6622)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-30 12:44:13 +00:00
Anshuman Pandey
3733c22a6f fix: file uploads and cluster setup docs (#6623) 2025-09-30 01:46:02 -07:00
Anshuman Pandey
5e5baa76ab fix: fixes the formbricks.sh redis undefined volume bug (#6604) 2025-09-25 13:55:43 +00:00
Dhruwang Jariwala
2153d2aa16 fix: replace button with div in IdBadge to prevent hydration issues (#6601) 2025-09-25 13:42:41 +00:00
Matti Nannt
7fa4862fd9 feat: make S3_REGION optional in storage client configuration (#6577) 2025-09-25 12:25:35 +00:00
Matti Nannt
411e9a26ee fix(ci): update release tag validation to accept format without v prefix (#6585) 2025-09-25 12:09:19 +00:00
Victor Hugo dos Santos
eb1349f205 fix: enhance JWT handling with improved encryption and decryption logic (#6596) 2025-09-25 11:45:08 +00:00
Johannes
5c25f25212 docs: remove beta note (#6593) 2025-09-24 02:51:58 -07:00
Victor Hugo dos Santos
6af81e46ee chore: improve Sentry API logs with correlation ID and request context (#6584) 2025-09-24 09:25:51 +00:00
Jakob Schott
7423fc9472 fix: Improve messaging for mobile users (#6579)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-23 10:13:00 +00:00
Victor Hugo dos Santos
1557ffcca1 feat: add redis migration script (#6575)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-09-22 11:18:02 +00:00
Piyush Gupta
5d53ed76ed fix: logic fallback cleanup (#6568) 2025-09-22 08:10:27 +00:00
Dhruwang Jariwala
ebd399e611 fix: block previews for completed and paused surveys (#6576) 2025-09-22 07:21:38 +00:00
Dhruwang Jariwala
843110b0d6 fix: followup toast (#6565) 2025-09-19 13:03:56 +00:00
Anshuman Pandey
51babf2f98 fix: minor csp change and removes uploads volume (#6566) 2025-09-19 10:20:38 +00:00
Victor Hugo dos Santos
6bc5f1e168 feat: add cache integration tests and update E2E workflow (#6551) 2025-09-19 08:44:31 +00:00
Piyush Gupta
c9016802e7 docs: updated screenshots in docs (#6562) 2025-09-18 19:19:14 +00:00
Anshuman Pandey
6a49fb4700 feat: adds one-click MinIO migration script for Formbricks 4.0 (#6553)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-09-18 16:23:03 +00:00
Dhruwang Jariwala
646921cd37 fix: logic issues (#6561) 2025-09-18 18:31:44 +02:00
Dhruwang Jariwala
34d3145fcd fix: broken churn survey template (#6559) 2025-09-18 11:18:39 +00:00
Dhruwang Jariwala
c3c06eb309 fix: empty container in template UI (#6556) 2025-09-18 06:45:20 +00:00
Dhruwang Jariwala
bf4c6238d5 fix: api key modal tweaks (#6552)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-17 15:00:42 +00:00
Dhruwang Jariwala
8972ef0fef fix: integration redirect links (#6555) 2025-09-17 14:59:35 +00:00
Matti Nannt
4e59924a5a fix: e2e tests issue due to security policy (#6558) 2025-09-17 16:54:07 +02:00
Matti Nannt
8b28353b79 fix: release tag extraction in release action (#6554) 2025-09-16 17:33:32 +00:00
Matti Nannt
abbc7a065b chore: update release pipeline for new infrastructure (#6541) 2025-09-16 10:33:24 +00:00
Harsh Bhat
00e8ee27a2 docs: Add redirect error handling (#6548) 2025-09-15 06:03:41 -07:00
Dhruwang Jariwala
379aeba71a fix: synced translations (#6547) 2025-09-15 10:19:02 +00:00
Anshuman Pandey
717adddeae feat: adds docs for s3 compatible storage (#6538)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-09-15 07:34:46 +00:00
Dhruwang Jariwala
41798266a0 fix: quota translations (#6546) 2025-09-15 07:04:40 +00:00
Matti Nannt
a93fa8ec76 chore: use stable tag to manage releases and ensure one-click-setup c… (#6540) 2025-09-12 17:03:13 +00:00
153 changed files with 4964 additions and 2202 deletions

View File

@@ -0,0 +1,179 @@
---
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
globs:
alwaysApply: false
---
# Review & Refine
Before finalizing any code changes, review your implementation against these quality standards:
## Core Principles
### DRY (Don't Repeat Yourself)
- Extract duplicated logic into reusable functions or hooks
- If the same code appears in multiple places, consolidate it
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
- Avoid copy-pasting code blocks
### Code Reduction
- Remove unnecessary code, comments, and abstractions
- Prefer built-in solutions over custom implementations
- Consolidate similar logic
- Remove dead code and unused imports
- Question if every line of code is truly needed
## React Best Practices
### Component Design
- Keep components focused on a single responsibility
- Extract complex logic into custom hooks
- Prefer composition over prop drilling
- Use children props and render props when appropriate
- Keep component files under 300 lines when possible
### Hooks Usage
- Follow Rules of Hooks (only call at top level, only in React functions)
- Extract complex `useEffect` logic into custom hooks
- Use `useMemo` and `useCallback` only when you have a measured performance issue
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
- Keep `useEffect` focused on a single concern
### State Management
- Colocate state as close as possible to where it's used
- Lift state only when necessary
- Use `useReducer` for complex state logic with multiple sub-values
- Avoid derived state - compute values during render instead
- Don't store values in state that can be computed from props
### Event Handlers
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
- Extract complex event handler logic into separate functions
- Avoid inline arrow functions in JSX when they contain complex logic
## TypeScript Best Practices
### Type Safety
- Prefer type inference over explicit types when possible
- Use `const` assertions for literal types
- Avoid `any` - use `unknown` if type is truly unknown
- Use discriminated unions for complex conditional logic
- Leverage type guards and narrowing
### Interface & Type Usage
- Use existing types from `@formbricks/types` - don't recreate them
- Prefer `interface` for object shapes that might be extended
- Prefer `type` for unions, intersections, and mapped types
- Define types close to where they're used unless they're shared
- Export types from index files for shared types
### Type Assertions
- Avoid type assertions (`as`) when possible
- Use type guards instead of assertions
- Only assert when you have more information than TypeScript
## Code Organization
### Separation of Concerns
- Separate business logic from UI rendering
- Extract API calls into separate functions or modules
- Keep data transformation separate from component logic
- Use custom hooks for stateful logic that doesn't render UI
### Function Clarity
- Functions should do one thing well
- Name functions clearly and descriptively
- Keep functions small (aim for under 20 lines)
- Extract complex conditionals into named boolean variables or functions
- Avoid deep nesting (max 3 levels)
### File Structure
- Group related functions together
- Order declarations logically (types → hooks → helpers → component)
- Keep imports organized (external → internal → relative)
- Consider splitting large files by concern
## Additional Quality Checks
### Performance
- Don't optimize prematurely - measure first
- Avoid creating new objects/arrays/functions in render unnecessarily
- Use keys properly in lists (stable, unique identifiers)
- Lazy load heavy components when appropriate
### Accessibility
- Use semantic HTML elements
- Include ARIA labels where needed
- Ensure keyboard navigation works
- Check color contrast and focus states
### Error Handling
- Handle error states in components
- Provide user feedback for failed operations
- Use error boundaries for component errors
- Log errors appropriately (avoid swallowing errors silently)
### Naming Conventions
- Use descriptive names (avoid abbreviations unless very common)
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
- Arrays should be plural (`users`, `choices`, `items`)
- Event handlers: `handleX` in components, `onX` for props
- Constants in UPPER_SNAKE_CASE only for true constants
### Code Readability
- Prefer early returns to reduce nesting
- Use destructuring to make code clearer
- Break complex expressions into named variables
- Add comments only when code can't be made self-explanatory
- Use whitespace to group related code
### Testing Considerations
- Write code that's easy to test (pure functions, clear inputs/outputs)
- Avoid hard-to-mock dependencies when possible
- Keep side effects at the edges of your code
## Review Checklist
Before submitting your changes, ask yourself:
1. **DRY**: Is there any duplicated logic I can extract?
2. **Clarity**: Would another developer understand this code easily?
3. **Simplicity**: Is this the simplest solution that works?
4. **Types**: Am I using TypeScript effectively?
5. **React**: Am I following React idioms and best practices?
6. **Performance**: Are there obvious performance issues?
7. **Separation**: Are concerns properly separated?
8. **Testing**: Is this code testable?
9. **Maintenance**: Will this be easy to change in 6 months?
10. **Deletion**: Can I remove any code and still accomplish the goal?
## When to Apply This Rule
Apply this rule:
- After implementing a feature but before marking it complete
- When you notice your code feels "messy" or complex
- Before requesting code review
- When you see yourself copy-pasting code
- After receiving feedback about code quality
Don't let perfect be the enemy of good, but always strive for:
**Simple, readable, maintainable code that does one thing well.**

View File

@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -166,6 +166,12 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
@@ -175,6 +181,12 @@ jobs:
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Disable rate limiting for E2E tests
run: |
echo "RATE_LIMITING_DISABLED=1" >> .env
echo "Rate limiting disabled for E2E tests"
shell: bash
- name: Run App
run: |
echo "Starting app with enterprise license..."
@@ -216,11 +228,14 @@ jobs:
if: env.AZURE_ENABLED == 'true'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
env:
CI: true
run: |
pnpm test:e2e
@@ -239,4 +254,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log

View File

@@ -1,8 +1,14 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -12,13 +18,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface LandingSidebarProps {
user: TUser;
@@ -66,10 +65,8 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />

View File

@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {

View File

@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { H4, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
interface ButtonInfo {
text: string;
@@ -41,7 +41,7 @@ export const SettingsCard = ({
id={title}>
<div className="flex justify-between border-b border-slate-200 px-4 pb-4">
<div>
<H4 className="font-medium capitalize tracking-normal">{title}</H4>
<H4 className="font-medium tracking-normal">{title}</H4>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -1,9 +1,3 @@
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -12,6 +6,12 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
@@ -285,7 +285,7 @@ describe("getEnvironmentState", () => {
expect(cache.withCache).toHaveBeenCalledWith(
expect.any(Function),
"fb:env:test-environment-id:state",
5 * 60 * 1000 // 5 minutes in milliseconds
60 * 1000 // 1 minutes in milliseconds
);
});

View File

@@ -1,4 +1,8 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
@@ -6,10 +10,6 @@ import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { getEnvironmentStateData } from "./data";
/**
@@ -80,6 +80,6 @@ export const getEnvironmentState = async (
return { data };
},
createCacheKey.environment.state(environmentId),
5 * 60 * 1000 // 5 minutes in milliseconds
60 * 1000 // 1 minutes in milliseconds
);
};

View File

@@ -0,0 +1 @@
export { GET } from "@/modules/api/v2/health/route";

View File

@@ -1,9 +1,9 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
@@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
@@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
@@ -110,6 +122,12 @@ describe("withV1ApiWrapper", () => {
}));
vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
});
test("logs and audits on error response with API key authentication", async () => {
@@ -161,10 +179,9 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
});
test("does not log Sentry if not 500", async () => {
@@ -269,10 +286,8 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
});
test("does not log on success response but still audits", async () => {

View File

@@ -1,3 +1,8 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
@@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
@@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
};

View File

@@ -114,9 +114,6 @@ export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
// Storage is considered configured if we have the minimum required settings:
// - S3_REGION and S3_BUCKET_NAME are always required
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
// Colors for Survey Bg

View File

@@ -1,36 +1,7 @@
import { describe, expect, test } from "vitest";
import {
capitalizeFirstLetter,
isCapitalized,
sanitizeString,
startsWithVowel,
truncate,
truncateText,
} from "./strings";
import { isCapitalized, sanitizeString, startsWithVowel, truncate, truncateText } from "./strings";
describe("String Utilities", () => {
describe("capitalizeFirstLetter", () => {
test("capitalizes the first letter of a string", () => {
expect(capitalizeFirstLetter("hello")).toBe("Hello");
});
test("returns empty string if input is null", () => {
expect(capitalizeFirstLetter(null)).toBe("");
});
test("returns empty string if input is empty string", () => {
expect(capitalizeFirstLetter("")).toBe("");
});
test("doesn't change already capitalized string", () => {
expect(capitalizeFirstLetter("Hello")).toBe("Hello");
});
test("handles single character string", () => {
expect(capitalizeFirstLetter("a")).toBe("A");
});
});
describe("truncate", () => {
test("returns the string as is if length is less than the specified length", () => {
expect(truncate("hello", 10)).toBe("hello");

View File

@@ -1,10 +1,3 @@
export const capitalizeFirstLetter = (string: string | null = "") => {
if (string === null) {
return "";
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
// write a function that takes a string and truncates it to the specified length
export const truncate = (str: string, length: number) => {
if (!str) return "";

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Zugriffskontrolle",
"add_api_key": "API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Access Control",
"add_api_key": "Add API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
@@ -1300,8 +1301,8 @@
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multiple Choice",
"convert_to_single_choice": "Convert to Single Choice",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"country": "Country",
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Contrôle d'accès",
"add_api_key": "Ajouter une clé API",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "アクセス制御",
"add_api_key": "APIキーを追加",
"api_key": "APIキー",
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
"usability_score_name": "システムユーザビリティスコアSUS"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Controle de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Controlo de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
@@ -1300,8 +1301,8 @@
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
"convert_to_single_choice": "Converter para Escolha Única",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"country": "País",
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "Control acces",
"add_api_key": "Adaugă Cheie API",
"api_key": "Cheie API",
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
@@ -1300,8 +1301,8 @@
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
"convert_to_single_choice": "Convertiți la alegere unică",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"country": "Țară",
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备",
"mobile_overlay_surveys_look_good": "别 担心 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "访问控制",
"add_api_key": "添加 API 密钥",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
@@ -1300,8 +1301,8 @@
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为多选",
"convert_to_single_choice": "转换为单选",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"country": "国家",
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
"usability_score_name": "系统 可用性 得分 ( SUS )"
}
}
}

View File

@@ -262,7 +262,9 @@
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
@@ -750,7 +752,6 @@
},
"project": {
"api_keys": {
"access_control": "存取控制",
"add_api_key": "新增 API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
@@ -2887,4 +2888,4 @@
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
}
}
}

View File

@@ -230,7 +230,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("value");
});
test("renders ResponseBadges for 'Consent' question (number)", () => {
@@ -258,7 +258,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {

View File

@@ -1,16 +1,3 @@
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
@@ -22,6 +9,18 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
interface RenderResponseProps {
responseData: TResponseDataValue;
@@ -104,9 +103,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return null;
return (
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
<p key={rowValueInSelectedLanguage} className="ph-no-capture my-1 font-normal text-slate-700">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);
@@ -126,7 +123,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -138,7 +135,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -150,7 +147,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
items={[{ value: responseData.toString() }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}

View File

@@ -0,0 +1,101 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};

View File

@@ -0,0 +1,29 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};

View File

@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});

View File

@@ -0,0 +1,15 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;

View File

@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -325,6 +354,7 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,

View File

@@ -1,8 +1,8 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
@@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
}));
// Mock SENTRY_DSN constant
@@ -232,7 +241,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -266,7 +275,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -303,7 +312,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();

View File

@@ -1,8 +1,8 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
}
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
correlationId,
error,
})
.error("API Error Details");
.error("API V2 Error Details");
};

View File

@@ -1,17 +1,18 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: ApiAuditLog
auditLog?: TApiAuditLog
): Response => {
logApiError(request, err, auditLog);
@@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
logAuditLog(request, auditLog);
};
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({

View File

@@ -1,7 +1,10 @@
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import {
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
operationId: "getPersonalizedSurveyLink",
@@ -9,6 +12,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
description: "Retrieves a personalized link for a specific survey.",
requestParams: {
path: ZContactLinkParams,
query: ZContactLinkQuery,
},
tags: ["Management API - Surveys - Contact Links"],
responses: {
@@ -20,6 +24,10 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
z.object({
data: z.object({
surveyUrl: z.string().url(),
expiresAt: z
.string()
.nullable()
.describe("The date and time the link expires, null if no expiration"),
}),
})
),

View File

@@ -8,7 +8,9 @@ import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contac
import {
TContactLinkParams,
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -19,9 +21,10 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
query: ZContactLinkQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const { params, query } = parsedInput;
if (!params) {
return handleApiError(request, {
@@ -92,12 +95,27 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
expiresAt = calculateExpirationDate(query.expirationDays);
}
const surveyUrlResult = await getContactSurveyLink(
params.contactId,
params.surveyId,
query?.expirationDays || undefined
);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
return responses.successResponse({
data: {
surveyUrl: surveyUrlResult.data,
expiresAt,
},
});
},
});

View File

@@ -20,4 +20,15 @@ export const ZContactLinkParams = z.object({
}),
});
export const ZContactLinkQuery = z.object({
expirationDays: z.coerce
.number()
.int()
.min(1)
.max(365)
.optional()
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
});
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;
export type TContactLinkQuery = z.infer<typeof ZContactLinkQuery>;

View File

@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { calculateExpirationDate } from "./utils";
describe("calculateExpirationDate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("calculates expiration date for positive days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(7);
const expectedDate = new Date("2024-01-22T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("handles zero expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(0);
expect(result).toBe(baseDate.toISOString());
});
test("handles negative expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(-5);
const expectedDate = new Date("2024-01-10T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("returns valid ISO string format", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(10);
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
expect(result).toMatch(isoRegex);
});
});

View File

@@ -0,0 +1,5 @@
export const calculateExpirationDate = (expirationDays: number) => {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + expirationDays);
return expirationDate.toISOString();
};

View File

@@ -1,7 +1,9 @@
import { logger } from "@formbricks/logger";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
@@ -11,7 +13,6 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
@@ -76,9 +77,7 @@ export const GET = async (
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
expiresAt = calculateExpirationDate(query.expirationDays);
}
// Generate survey links for each contact

View File

@@ -1,3 +1,5 @@
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
@@ -35,6 +37,7 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...healthPaths,
...rolePaths,
...mePaths,
...responsePaths,
@@ -55,6 +58,10 @@ const document = createDocument({
},
],
tags: [
{
name: "Health",
description: "Operations for checking critical application dependencies health status.",
},
{
name: "Roles",
description: "Operations for managing roles.",
@@ -114,6 +121,7 @@ const document = createDocument({
},
},
schemas: {
health: ZOverallHealthStatus,
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,

View File

@@ -66,8 +66,21 @@ export const authOptions: NextAuthOptions = {
throw new Error("Invalid credentials");
}
// Validate password length to prevent CPU DoS attacks
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
if (credentials.password && credentials.password.length > 128) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
}
throw new Error("Invalid credentials");
}
// Use a control hash when user doesn't exist to maintain constant timing.
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
let user;
try {
// Perform database lookup
user = await prisma.user.findUnique({
where: {
email: credentials?.email,
@@ -79,6 +92,12 @@ export const authOptions: NextAuthOptions = {
throw Error("Internal server error. Please try again later");
}
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
// Use actual hash if user exists, control hash if user doesn't exist
const hashToVerify = user?.password || controlHash;
const isValid = await verifyPassword(credentials.password, hashToVerify);
// Now check all conditions after constant-time operations are complete
if (!user) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
@@ -96,8 +115,6 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
if (await shouldLogAuthFailure(user.email)) {
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);

View File

@@ -1,5 +1,14 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { cn } from "@/lib/cn";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -10,19 +19,13 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
const ZLoginForm = z.object({
email: z.string().email(),
password: z.string().min(8),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" }),
totpCode: z.string().optional(),
backupCode: z.string().optional(),
});

View File

@@ -1,7 +1,7 @@
export const rateLimitConfigs = {
// Authentication endpoints - stricter limits for security
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour

View File

@@ -1,13 +1,13 @@
"use client";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps {
@@ -170,14 +170,13 @@ export const PricingCard = ({
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
<Button
variant="secondary"
loading={loading}
onClick={async () => {
setLoading(true);
await onManageSubscription();
setLoading(false);
}}
className="flex justify-center">
className="flex justify-center bg-[#635bff]">
{t("environments.settings.billing.manage_subscription")}
</Button>
)}

View File

@@ -1,14 +1,13 @@
"use client";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
import { getCloudPricingData } from "../api/lib/constants";
import { BillingSlider } from "./billing-slider";
@@ -141,7 +140,7 @@ export const PricingTable = ({
<div className="flex w-full">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
{capitalizeFirstLetter(organization.billing.plan)}
<span className="capitalize">{organization.billing.plan}</span>
{cancellingOn && (
<Badge
className="mx-2"
@@ -175,7 +174,7 @@ export const PricingTable = ({
)}
</div>
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-sm dark:bg-slate-800">
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",

View File

@@ -1,5 +1,4 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -59,7 +58,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);

View File

@@ -1,6 +1,3 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { createCacheKey } from "@formbricks/cache";
@@ -9,6 +6,9 @@ import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TBaseFilter } from "@formbricks/types/segment";
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSegments = reactCache(
async (environmentId: string) =>
@@ -34,7 +34,7 @@ export const getSegments = reactCache(
}
},
createCacheKey.environment.segments(environmentId),
5 * 60 * 1000 // 5 minutes in milliseconds
60 * 1000 // 1 minutes in milliseconds
)
);

View File

@@ -1,34 +1,5 @@
"use client";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
@@ -64,6 +35,35 @@ import {
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
interface TSegmentFilterProps {
@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -2,6 +2,7 @@
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -37,26 +38,30 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.duplicate")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
</div>
</div>
))}

View File

@@ -25,6 +25,15 @@ vi.mock("../actions", () => ({
updateInviteAction: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: (role: string) => ({
isOwner: role === "owner",
isManager: role === "manager",
isMember: role === "member",
isBilling: role === "billing",
}),
}));
describe("EditMembershipRole Component", () => {
const mockRouter = {
refresh: vi.fn(),
@@ -53,15 +62,21 @@ describe("EditMembershipRole Component", () => {
describe("Rendering", () => {
test("renders a dropdown when user is owner", () => {
render(<EditMembershipRole {...defaultProps} />);
render(<EditMembershipRole {...defaultProps} isUserManagementDisabledFromUi={false} />);
const button = screen.queryByRole("button-role");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("Member");
expect(button).toHaveTextContent("member");
});
test("renders a badge when user is not owner or manager", () => {
render(<EditMembershipRole {...defaultProps} currentUserRole="member" />);
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="member"
isUserManagementDisabledFromUi={false}
/>
);
const badge = screen.queryByRole("badge-role");
expect(badge).toBeInTheDocument();
@@ -70,21 +85,42 @@ describe("EditMembershipRole Component", () => {
});
test("disables the dropdown when editing own role", () => {
render(<EditMembershipRole {...defaultProps} memberId="user-456" userId="user-456" />);
render(
<EditMembershipRole
{...defaultProps}
memberId="user-456"
userId="user-456"
isUserManagementDisabledFromUi={false}
/>
);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when the user is the only owner", () => {
render(<EditMembershipRole {...defaultProps} memberRole="owner" doesOrgHaveMoreThanOneOwner={false} />);
render(
<EditMembershipRole
{...defaultProps}
memberRole="owner"
doesOrgHaveMoreThanOneOwner={false}
isUserManagementDisabledFromUi={false}
/>
);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when a manager tries to edit an owner", () => {
render(<EditMembershipRole {...defaultProps} currentUserRole="manager" memberRole="owner" />);
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="manager"
memberRole="owner"
isUserManagementDisabledFromUi={false}
/>
);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();

View File

@@ -1,7 +1,12 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
@@ -11,12 +16,6 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { updateInviteAction, updateMembershipAction } from "../actions";
interface Role {
@@ -104,7 +103,7 @@ export function EditMembershipRole({
size="sm"
variant="secondary"
role="button-role">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<span className="ml-1 capitalize">{memberRole}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -128,5 +127,5 @@ export function EditMembershipRole({
);
}
return <Badge size="tiny" type="gray" role="badge-role" text={capitalizeFirstLetter(memberRole)} />;
return <Badge size="tiny" type="gray" role="badge-role" text={memberRole} className="capitalize" />;
}

View File

@@ -1,11 +1,10 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Label } from "@/modules/ui/components/label";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { convertDateTimeStringShort } from "@/lib/time";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
webhook: Webhook;
@@ -50,8 +49,8 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<Label className="text-slate-500">
{t("environments.integrations.webhooks.created_by_third_party")}
</Label>
<p className="text-sm text-slate-900">
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
<p className="text-sm capitalize text-slate-900">
{webhook.source === "user" ? "No" : webhook.source}
</p>
</div>

View File

@@ -1,12 +1,11 @@
"use client";
import { timeSince } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
@@ -82,7 +81,7 @@ export const WebhookRowData = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge type="gray" size="tiny" text={capitalizeFirstLetter(webhook.source) || t("common.user")} />
<Badge type="gray" size="tiny" text={webhook.source || t("common.user")} className="capitalize" />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}

View File

@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -24,7 +23,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -220,10 +219,10 @@ export const AddApiKeyModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
<DialogBody className="space-y-4 overflow-y-auto py-4">
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
@@ -348,43 +347,31 @@ export const AddApiKeyModal = ({
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
<Label>{t("environments.project.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
<div key={key} className="mt-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label>Read</Label>
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
disabled={selectedOrganizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label>Write</Label>
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
/>
</div>
</div>
</div>
))}
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>

View File

@@ -1,7 +1,6 @@
import { ApiKeyPermission } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
@@ -104,6 +103,8 @@ describe("ViewPermissionModal", () => {
setOpen: vi.fn(),
projects: mockProjects,
apiKey: mockApiKey,
onSubmit: vi.fn(),
isUpdating: false,
};
test("renders the modal with correct title", () => {
@@ -154,7 +155,7 @@ describe("ViewPermissionModal", () => {
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
});
});

View File

@@ -1,6 +1,5 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
@@ -22,7 +21,7 @@ import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Fragment, useEffect } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -168,36 +167,28 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-2">
<div className="space-y-4">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
{Object.keys(organizationAccess).map((key) => (
<div key={key} className="mb-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Read</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read || organizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Write</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</div>
</div>
))}
</div>
</div>
</form>

View File

@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
import { hasPermission } from "./utils";
describe("hasPermission", () => {
const envId = "env1";
@@ -83,17 +83,3 @@ describe("hasPermission", () => {
expect(hasPermission(permissions, "other", "GET")).toBe(false);
});
});
describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for accessControl", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
"environments.project.api_keys.access_control"
);
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
});
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
});
});

View File

@@ -1,4 +1,3 @@
import { TFnType } from "@tolgee/react";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -43,15 +42,6 @@ export const hasPermission = (
}
};
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
switch (key) {
case "accessControl":
return t("environments.project.api_keys.access_control");
default:
return key;
}
};
export const hasOrganizationAccess = (
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType

View File

@@ -1,11 +1,11 @@
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
@@ -51,10 +51,6 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
}));
vi.mock("@/lib/utils/strings", () => ({
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
createActionClassAction: vi.fn(),
}));
@@ -209,7 +205,7 @@ describe("ActionActivityTab", () => {
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
expect(screen.getByText("noCode")).toBeInTheDocument(); // Type text (now lowercase, capitalized via CSS)
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
});

View File

@@ -1,8 +1,12 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
import { createActionClassAction } from "@/modules/survey/editor/actions";
@@ -10,11 +14,6 @@ import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
interface ActivityTabProps {
actionClass: TActionClass;
@@ -152,7 +151,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
</div>
</div>
<div className="">

View File

@@ -1,5 +1,19 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -10,20 +24,6 @@ import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
determineImageUploaderVisibility,
getChoiceLabel,
@@ -50,7 +50,6 @@ interface QuestionFormInputProps {
label: string;
maxLength?: number;
placeholder?: string;
ref?: RefObject<HTMLInputElement | null>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
className?: string;
locale: TUserLocale;

View File

@@ -1,5 +1,19 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
@@ -15,20 +29,6 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Label } from "@/modules/ui/components/label";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ConditionalLogicProps {
localSurvey: TSurvey;

View File

@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { ConditionsEditor } from "@/modules/ui/components/conditions-editor";
import { useTranslate } from "@tolgee/react";
import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorConditionsProps {
conditions: TConditionGroup;

View File

@@ -1,5 +1,3 @@
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
@@ -8,6 +6,8 @@ import {
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { LogicEditor } from "./logic-editor";
// Mock the subcomponents to isolate the LogicEditor component

View File

@@ -1,5 +1,9 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
@@ -11,10 +15,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface LogicEditorProps {
localSurvey: TSurvey;

View File

@@ -1,335 +1,270 @@
import { createI18nString } from "@/lib/i18n/utils";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyMatrixQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { MatrixQuestionForm } from "./matrix-question-form";
// Mock cuid2 to track CUID generation
let cuidIndex = 0;
vi.mock("@paralleldrive/cuid2", () => ({
default: {
createId: vi.fn(() => `cuid${cuidIndex++}`),
},
}));
// Mock window.matchMedia - required for useAutoAnimate
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock findOptionUsedInLogic
vi.mock("@/modules/survey/editor/lib/utils", () => ({
findOptionUsedInLogic: vi.fn(),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
}));
// Mock tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: null,
}),
verticalListSortingStrategy: () => {},
}));
// Mock QuestionFormInput component
// Keep QuestionFormInput simple and forward keydown
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion, onKeyDown }) => (
<div data-testid={`question-input-${id}`}>
<input
data-testid={`input-${id}`}
onChange={(e) => {
if (updateMatrixLabel) {
const type = id.startsWith("row") ? "row" : "column";
const index = parseInt(id.split("-")[1]);
updateMatrixLabel(index, type, { default: e.target.value });
} else if (updateQuestion) {
updateQuestion(0, { [id]: { default: e.target.value } });
}
}}
value={value?.default || ""}
onKeyDown={onKeyDown}
/>
</div>
)),
QuestionFormInput: ({ id, value, onKeyDown }: { id: string; value: any; onKeyDown?: any }) => (
<input
data-testid={`qfi-${id}`}
value={value?.en || value?.de || value?.default || ""}
onChange={() => {}}
onKeyDown={onKeyDown}
/>
),
}));
// Mock ShuffleOptionSelect component
vi.mock("@/modules/ui/components/shuffle-option-select", () => ({
ShuffleOptionSelect: vi.fn(() => <div data-testid="shuffle-option-select" />),
}));
describe("MatrixQuestionForm - handleKeyDown", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
// Mock TooltipRenderer component
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: vi.fn(({ children }) => (
<div data-testid="tooltip-renderer">
{children}
<button>Delete</button>
</div>
)),
}));
// Mock validation
vi.mock("../lib/validation", () => ({
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
}));
// Mock survey languages
const mockSurveyLanguages: TSurveyLanguage[] = [
{
default: true,
enabled: true,
language: {
id: "en",
code: "en",
alias: "English",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
},
},
];
// Mock matrix question
const mockMatrixQuestion: TSurveyMatrixQuestion = {
id: "matrix-1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: createI18nString("Matrix Question", ["en"]),
subheader: createI18nString("Please rate the following", ["en"]),
required: false,
logic: [],
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
],
shuffleOption: "none",
};
// Mock survey
const mockSurvey: TSurvey = {
id: "survey-1",
name: "Test Survey",
questions: [mockMatrixQuestion],
languages: mockSurveyLanguages,
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const defaultProps = {
localSurvey: mockSurvey,
question: mockMatrixQuestion,
questionIdx: 0,
updateQuestion: mockUpdateQuestion,
selectedLanguageCode: "en",
setSelectedLanguageCode: vi.fn(),
isInvalid: false,
locale: "en-US" as TUserLocale,
isStorageConfigured: true,
};
describe("MatrixQuestionForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
cuidIndex = 0;
});
test("renders the matrix question form with rows and columns", () => {
render(<MatrixQuestionForm {...defaultProps} isStorageConfigured={true} />);
const makeSurvey = (languages: Array<Pick<TSurveyLanguage, "language" | "default">>): TSurvey =>
({
id: "s1",
name: "Survey",
type: "link",
languages: languages as unknown as TSurveyLanguage[],
questions: [] as any,
endings: [] as any,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env1",
}) as unknown as TSurvey;
expect(screen.getByTestId("question-input-headline")).toBeInTheDocument();
const langDefault: TSurveyLanguage = {
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage;
// Check for rows and columns
expect(screen.getByTestId("question-input-row-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-row-1")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-0")).toBeInTheDocument();
expect(screen.getByTestId("question-input-column-1")).toBeInTheDocument();
// Check for shuffle options
expect(screen.getByTestId("shuffle-option-select")).toBeInTheDocument();
const baseQuestion = (): TSurveyMatrixQuestion => ({
id: "q1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [
{ id: "r1", label: { default: "Row 1" } },
{ id: "r2", label: { default: "" } },
],
columns: [
{ id: "c1", label: { default: "Col 1" } },
{ id: "c2", label: { default: "" } },
],
shuffleOption: "none",
});
test("adds description when button is clicked", async () => {
const user = userEvent.setup();
const propsWithoutSubheader = {
...defaultProps,
question: {
...mockMatrixQuestion,
subheader: undefined,
},
};
test("Enter on last row adds a new row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const { getByText } = render(
<MatrixQuestionForm {...propsWithoutSubheader} isStorageConfigured={true} />
const updateQuestion = vi.fn();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const addDescriptionButton = getByText("environments.surveys.edit.add_description");
await user.click(addDescriptionButton);
const lastRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(lastRowInput, "{enter}");
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
subheader: expect.any(Object),
});
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.rows.length).toBe(3);
expect(payload.rows[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
});
test("renders subheader input when subheader is defined", () => {
render(<MatrixQuestionForm {...defaultProps} />);
test("Enter on non-last row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
const updateQuestion = vi.fn();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{enter}");
expect(updateQuestion).not.toHaveBeenCalled();
});
test("deletes a row when delete button is clicked", async () => {
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(-1);
test("Enter on last column adds a new column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const deleteButtons = await findAllByTestId("tooltip-renderer");
// First delete button is for the first column
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
rows: [mockMatrixQuestion.rows[1], mockMatrixQuestion.rows[2]],
});
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const lastColInput = screen.getByTestId("qfi-column-1");
await userEvent.type(lastColInput, "{enter}");
expect(updateQuestion).toHaveBeenCalledTimes(1);
const [, payload] = updateQuestion.mock.calls[0];
expect(payload.columns.length).toBe(3);
expect(payload.columns[2]).toEqual(
expect.objectContaining({ id: expect.any(String), label: expect.objectContaining({ default: "" }) })
);
});
test("doesn't delete a row if it would result in less than 2 rows", async () => {
const user = userEvent.setup();
const propsWithMinRows = {
...defaultProps,
question: {
...mockMatrixQuestion,
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
],
},
};
test("Enter on non-last column focuses next column", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithMinRows} />);
const updateQuestion = vi.fn();
// Try to delete rows until there are only 2 left
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
// Try to delete another row, which should fail
vi.mocked(mockUpdateQuestion).mockClear();
await user.click(deleteButtons[1].querySelector("button") as HTMLButtonElement);
const firstColInput = screen.getByTestId("qfi-column-0");
await userEvent.type(firstColInput, "{enter}");
// The mockUpdateQuestion should not be called again
expect(mockUpdateQuestion).not.toHaveBeenCalled();
expect(updateQuestion).not.toHaveBeenCalled();
});
test("handles row input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
test("Arrow Down on row focuses next row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const rowInput = getByTestId("input-row-0");
await user.clear(rowInput);
await user.type(rowInput, "New Row Label");
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalled();
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
const firstRowInput = screen.getByTestId("qfi-row-0");
await userEvent.type(firstRowInput, "{arrowdown}");
expect(updateQuestion).not.toHaveBeenCalled();
});
test("handles column input changes", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
test("Arrow Up on row focuses previous row", async () => {
const question = baseQuestion();
const localSurvey = makeSurvey([langDefault]);
(localSurvey as any).questions = [question];
const columnInput = getByTestId("input-column-0");
await user.clear(columnInput);
await user.type(columnInput, "New Column Label");
const updateQuestion = vi.fn();
expect(mockUpdateQuestion).toHaveBeenCalled();
});
render(
<MatrixQuestionForm
localSurvey={localSurvey}
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
isStorageConfigured={true}
/>
);
test("prevents deletion of a row used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
const secondRowInput = screen.getByTestId("qfi-row-1");
await userEvent.type(secondRowInput, "{arrowup}");
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
test("prevents deletion of a column used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this column is used in logic
const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
// Column delete buttons are after row delete buttons
const deleteButtons = await findAllByTestId("tooltip-renderer");
// Click the first column delete button (index 2)
await user.click(deleteButtons[2].querySelector("button") as HTMLButtonElement);
expect(mockUpdateQuestion).not.toHaveBeenCalled();
expect(updateQuestion).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -17,6 +10,13 @@ import { type JSX, useCallback } from "react";
import toast from "react-hot-toast";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface MatrixQuestionFormProps {
@@ -45,17 +45,24 @@ export const MatrixQuestionForm = ({
const languageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate();
const focusItem = (targetIdx: number, type: "row" | "column") => {
const input = document.querySelector(`input[id="${type}-${targetIdx}"]`) as HTMLInputElement;
if (input) input.focus();
};
// Function to add a new Label input field
const handleAddLabel = (type: "row" | "column") => {
if (type === "row") {
const updatedRows = [...question.rows, { id: createId(), label: createI18nString("", languageCodes) }];
updateQuestion(questionIdx, { rows: updatedRows });
setTimeout(() => focusItem(updatedRows.length - 1, type), 0);
} else {
const updatedColumns = [
...question.columns,
{ id: createId(), label: createI18nString("", languageCodes) },
];
updateQuestion(questionIdx, { columns: updatedColumns });
setTimeout(() => focusItem(updatedColumns.length - 1, type), 0);
}
};
@@ -112,10 +119,30 @@ export const MatrixQuestionForm = ({
}
};
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column") => {
const handleKeyDown = (e: React.KeyboardEvent, type: "row" | "column", currentIndex: number) => {
const items = type === "row" ? question.rows : question.columns;
if (e.key === "Enter") {
e.preventDefault();
handleAddLabel(type);
if (currentIndex === items.length - 1) {
handleAddLabel(type);
} else {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (currentIndex + 1 < items.length) {
focusItem(currentIndex + 1, type);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (currentIndex > 0) {
focusItem(currentIndex - 1, type);
}
}
};
@@ -230,7 +257,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("row", index)}
onKeyDown={(e) => handleKeyDown(e, "row")}
onKeyDown={(e) => handleKeyDown(e, "row", index)}
canDelete={question.rows.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
@@ -276,7 +303,7 @@ export const MatrixQuestionForm = ({
questionIdx={questionIdx}
updateMatrixLabel={updateMatrixLabel}
onDelete={(index) => handleDeleteLabel("column", index)}
onKeyDown={(e) => handleKeyDown(e, "column")}
onKeyDown={(e) => handleKeyDown(e, "column", index)}
canDelete={question.columns.length > 2}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}

View File

@@ -1,8 +1,5 @@
"use client";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslate } from "@tolgee/react";
@@ -15,6 +12,9 @@ import {
TSurveyMatrixQuestionChoice,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface MatrixSortableItemProps {
choice: TSurveyMatrixQuestionChoice;

View File

@@ -72,7 +72,6 @@ describe("MultipleChoiceQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
locale="en-US"
lastQuestion={false}
isStorageConfigured={true}
/>
);

View File

@@ -1,12 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -23,13 +16,19 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
interface MultipleChoiceQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyMultipleChoiceQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -248,28 +247,27 @@ export const MultipleChoiceQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>

View File

@@ -1,5 +1,21 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
@@ -24,22 +40,6 @@ import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/su
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface QuestionCardProps {
localSurvey: TSurvey;
@@ -301,7 +301,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -314,7 +313,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
@@ -453,7 +451,6 @@ export const QuestionCard = ({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}

View File

@@ -7,7 +7,13 @@ import { QuestionOptionChoice } from "./question-option-choice";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: (props: any) => (
<div data-testid="question-form-input" className={props.className}></div>
<input
data-testid="question-form-input"
className={props.className}
onKeyDown={props.onKeyDown}
value={props.value?.default || props.value?.en || props.value?.de || ""}
onChange={() => {}}
/>
),
}));
@@ -70,6 +76,81 @@ describe("QuestionOptionChoice", () => {
expect(addButton).toBeDefined();
});
test("pressing Enter on last choice adds a new choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice2", label: { default: "Choice 2" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [{ id: "choice1", label: { default: "Choice 1" } }, choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={1}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
expect(addChoice).toHaveBeenCalledWith(1);
});
test("pressing Enter on non-last choice focuses next choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice, { id: "choice2", label: { default: "Choice 2" } }],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={{ languages: [{ language: { code: "default" }, default: true }] } as any}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[{ language: { code: "default" } as any, enabled: true, default: true } as any]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
isStorageConfigured={true}
/>
);
const input = screen.getByTestId("question-form-input");
await userEvent.type(input, "{enter}");
// Should not add a new choice (not the last one)
expect(addChoice).not.toHaveBeenCalled();
});
test("should call deleteChoice when the 'Delete choice' button is clicked for a standard choice", async () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {

View File

@@ -1,10 +1,5 @@
"use client";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslate } from "@tolgee/react";
@@ -18,6 +13,11 @@ import {
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { isLabelValidForAllLanguages } from "../lib/validation";
interface ChoiceProps {
@@ -72,6 +72,17 @@ export const QuestionOptionChoice = ({
transform: CSS.Translate.toString(transform),
};
const focusChoiceInput = (targetIdx: number) => {
const input = document.querySelector(`input[id="choice-${targetIdx}"]`) as HTMLInputElement;
input?.focus();
};
const addChoiceAndFocus = (idx: number) => {
addChoice(idx);
// Wait for DOM update before focusing the new input
setTimeout(() => focusChoiceInput(idx + 1), 0);
};
return (
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
{/* drag handle */}
@@ -101,6 +112,32 @@ export const QuestionOptionChoice = ({
className={`${choice.id === "other" ? "border border-dashed" : ""} mt-0`}
locale={locale}
isStorageConfigured={isStorageConfigured}
onKeyDown={(e) => {
if (e.key === "Enter" && choice.id !== "other") {
e.preventDefault();
const lastChoiceIdx = question.choices.findLastIndex((c) => c.id !== "other");
if (choiceIdx === lastChoiceIdx) {
addChoiceAndFocus(choiceIdx);
} else {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (choiceIdx + 1 < question.choices.length) {
focusChoiceInput(choiceIdx + 1);
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (choiceIdx > 0) {
focusChoiceInput(choiceIdx - 1);
}
}
}}
/>
{choice.id === "other" && (
<QuestionFormInput
@@ -110,9 +147,8 @@ export const QuestionOptionChoice = ({
label={""}
questionIdx={questionIdx}
value={
question.otherOptionPlaceholder
? question.otherOptionPlaceholder
: createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
question.otherOptionPlaceholder ??
createI18nString(t("environments.surveys.edit.please_specify"), surveyLanguageCodes)
}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
@@ -127,7 +163,7 @@ export const QuestionOptionChoice = ({
)}
</div>
<div className="flex gap-2">
{question.choices && question.choices.length > 2 && (
{question.choices?.length > 2 && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_choice")}>
<Button
variant="secondary"
@@ -149,7 +185,7 @@ export const QuestionOptionChoice = ({
aria-label="Add choice below"
onClick={(e) => {
e.preventDefault();
addChoice(choiceIdx);
addChoiceAndFocus(choiceIdx);
}}>
<PlusIcon />
</Button>

View File

@@ -88,7 +88,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -135,7 +134,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);
@@ -190,7 +188,6 @@ describe("RankingQuestionForm", () => {
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
isStorageConfigured={true}
/>
);

View File

@@ -1,11 +1,5 @@
"use client";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
@@ -15,13 +9,18 @@ import { PlusIcon } from "lucide-react";
import { type JSX, useEffect, useRef, useState } from "react";
import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { QuestionOptionChoice } from "@/modules/survey/editor/components/question-option-choice";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
interface RankingQuestionFormProps {
localSurvey: TSurvey;
question: TSurveyRankingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
@@ -195,28 +194,27 @@ export const RankingQuestionForm = ({
}}>
<SortableContext items={question.choices} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2" ref={parent}>
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
{question.choices?.map((choice, choiceIdx) => (
<QuestionOptionChoice
key={choice.id}
choice={choice}
choiceIdx={choiceIdx}
questionIdx={questionIdx}
updateChoice={updateChoice}
deleteChoice={deleteChoice}
addChoice={addChoice}
isInvalid={isInvalid}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={surveyLanguages}
question={question}
updateQuestion={updateQuestion}
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
))}
</div>
</SortableContext>
</DndContext>

View File

@@ -8,7 +8,7 @@ import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { CopyPlusIcon, TrashIcon } from "lucide-react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -154,7 +154,7 @@ export const FollowUpItem = ({
setDeleteFollowUpModalOpen(true);
}}
aria-label={t("common.delete")}>
<TrashIcon className="h-4 w-4 text-slate-500" />
<Trash2Icon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
@@ -167,7 +167,7 @@ export const FollowUpItem = ({
duplicateFollowUp();
}}
aria-label={t("common.duplicate")}>
<CopyPlusIcon className="h-4 w-4 text-slate-500" />
<CopyIcon className="h-4 w-4 text-slate-500" />
</Button>
</TooltipRenderer>
</div>

View File

@@ -1,3 +1,6 @@
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
@@ -16,9 +19,6 @@ import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
@@ -59,7 +59,7 @@ export const renderSurvey = async ({
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
if (survey.status !== "inProgress") {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive

View File

@@ -1,7 +1,7 @@
import { cn } from "@/modules/ui/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRightIcon, EllipsisIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/modules/ui/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
@@ -15,7 +15,10 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500", className)}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 hover:text-slate-700",
className
)}
{...props}
/>
)
@@ -32,7 +35,7 @@ const BreadcrumbItem = React.forwardRef<
<li
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:outline hover:outline-slate-300",
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
isActive && "bg-slate-100 outline outline-slate-300",
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
className
@@ -80,14 +83,15 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span"
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -1,14 +1,14 @@
"use client";
import { Column } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Column } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
interface ColumnSettingsDropdownProps<T> {
column: Column<T>;
@@ -29,7 +29,6 @@ export const ColumnSettingsDropdown = <T,>({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="capitalize"
onClick={() => {
column.toggleVisibility(false);
}}
@@ -39,7 +38,6 @@ export const ColumnSettingsDropdown = <T,>({
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="capitalize"
onClick={() => setIsTableSettingsModalOpen(true)}
icon={<SettingsIcon className="h-4 w-4" />}>
<div className="flex items-center space-x-2">

View File

@@ -64,6 +64,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
expect(screen.getByText("2 common.contacts common.selected")).toBeInTheDocument();
@@ -82,6 +83,7 @@ describe("SelectedRowSettings", () => {
updateRowList={updateRowList}
deleteAction={deleteAction}
type="response"
isQuotasAllowed={false}
/>
);
expect(screen.queryByText("common.download")).toBeNull();
@@ -95,6 +97,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
fireEvent.click(screen.getByText("common.download"));
@@ -115,6 +118,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
// open delete dialog
@@ -136,6 +140,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
// open delete menu (trigger button)
@@ -156,6 +161,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
isQuotasAllowed={false}
/>
);
// open delete dialog
@@ -177,6 +183,7 @@ describe("SelectedRowSettings", () => {
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
isQuotasAllowed={false}
/>
);
// open delete menu (trigger button)

View File

@@ -1,6 +1,11 @@
"use client";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Table } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { ArrowDownToLineIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { Button } from "@/modules/ui/components/button";
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
@@ -11,12 +16,6 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import { Table } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { ArrowDownToLineIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TResponseWithQuotas } from "@formbricks/types/responses";
interface SelectedRowSettingsProps<T> {
table: Table<T>;
@@ -75,14 +74,16 @@ export const SelectedRowSettings = <T,>({
// Update the row list UI
updateRowList(rowsToBeDeleted);
toast.success(t("common.table_items_deleted_successfully", { type: capitalizeFirstLetter(type) }));
const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
toast.success(t("common.table_items_deleted_successfully", { type: capitalizedType }));
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
toast.error(
t("common.an_unknown_error_occurred_while_deleting_table_items", {
type: capitalizeFirstLetter(type),
type: capitalizedType,
})
);
}

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { Check, Copy } from "lucide-react";
import React from "react";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface BadgeContentProps {
id: string | number;

View File

@@ -1,38 +1,51 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test } from "vitest";
import { NoMobileOverlay } from "./index";
// Mock the tolgee translation
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) =>
key === "common.mobile_overlay_text" ? "Please use desktop to access this section" : key,
}),
}));
describe("NoMobileOverlay", () => {
afterEach(() => {
cleanup();
});
test("renders overlay with correct text", () => {
test("renders title and paragraphs", () => {
render(<NoMobileOverlay />);
expect(screen.getByText("Please use desktop to access this section")).toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 1, name: "common.mobile_overlay_title" })
).toBeInTheDocument();
expect(screen.getByText("common.mobile_overlay_app_works_best_on_desktop")).toBeInTheDocument();
expect(screen.getByText("common.mobile_overlay_surveys_look_good")).toBeInTheDocument();
});
test("has proper z-index for overlay", () => {
test("has proper overlay classes (z-index and responsive hide)", () => {
render(<NoMobileOverlay />);
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
const overlay = document.querySelector("div.fixed");
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveClass("z-[9999]");
});
test("has responsive layout with sm:hidden class", () => {
render(<NoMobileOverlay />);
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
expect(overlay).toHaveClass("sm:hidden");
});
test("renders learn more link with correct href", () => {
render(<NoMobileOverlay />);
const link = screen.getByRole("link", { name: "common.learn_more" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://formbricks.com/docs/xm-and-surveys/overview");
});
test("stacks icons with maximize centered inside smartphone", () => {
const { container } = render(<NoMobileOverlay />);
const wrapper = container.querySelector("div.relative.h-16.w-16");
expect(wrapper).toBeInTheDocument();
const phoneSvg = wrapper?.querySelector("svg.h-16.w-16");
expect(phoneSvg).toBeInTheDocument();
const expandSvg = wrapper?.querySelector("svg.absolute");
expect(expandSvg).toBeInTheDocument();
expect(expandSvg).toHaveClass("left-1/2", "top-1/3", "-translate-x-1/2", "-translate-y-1/3");
});
});

View File

@@ -1,20 +1,35 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { SmartphoneIcon, XIcon } from "lucide-react";
import { ExternalLinkIcon, Maximize2Icon, SmartphoneIcon } from "lucide-react";
import { Button } from "@/modules/ui/components/button";
export const NoMobileOverlay = () => {
const { t } = useTranslate();
return (
<>
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
<div className="relative h-full w-full bg-slate-50"></div>
<div className="bg-slate-850 absolute mx-8 flex flex-col items-center gap-6 rounded-lg px-8 py-10 text-center">
<XIcon className="absolute top-14 h-8 w-8 text-slate-500" />
<SmartphoneIcon className="h-16 w-16 text-slate-500" />
<p className="text-slate-500">{t("common.mobile_overlay_text")}</p>
<div className="fixed inset-0 z-[9999] sm:hidden">
<div className="absolute inset-0 bg-slate-50"></div>
<div className="relative mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<div className="relative h-16 w-16">
<SmartphoneIcon className="text-muted-foreground h-16 w-16" />
<Maximize2Icon className="text-muted-foreground absolute left-1/2 top-1/3 h-5 w-5 -translate-x-1/2 -translate-y-1/3" />
</div>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
{t("common.mobile_overlay_title")}
</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("common.mobile_overlay_app_works_best_on_desktop")}
</p>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("common.mobile_overlay_surveys_look_good")}
</p>
<Button variant="default" asChild className="mt-8">
<a href="https://formbricks.com/docs/xm-and-surveys/overview">
{t("common.learn_more")}
<ExternalLinkIcon />
</a>
</Button>
</div>
</>
</div>
);
};

View File

@@ -11,7 +11,7 @@ describe("PageHeader", () => {
test("renders page title correctly", () => {
render(<PageHeader pageTitle="Dashboard" />);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Dashboard")).toHaveClass("text-3xl font-bold text-slate-800 capitalize");
expect(screen.getByText("Dashboard")).toHaveClass("text-3xl font-bold text-slate-800");
});
test("renders with CTA", () => {

View File

@@ -10,7 +10,7 @@ export const PageHeader = ({ cta, pageTitle, children }: PageHeaderProps) => {
return (
<div className="border-b border-slate-200">
<div className="flex items-center justify-between space-x-4 pb-4">
<h1 className={cn("text-3xl font-bold capitalize text-slate-800")}>{pageTitle}</h1>
<h1 className={cn("text-3xl font-bold text-slate-800")}>{pageTitle}</h1>
{cta}
</div>
{children}

View File

@@ -21,7 +21,7 @@
"@aws-sdk/client-s3": "3.879.0",
"@aws-sdk/s3-presigned-post": "3.879.0",
"@aws-sdk/s3-request-presigner": "3.879.0",
"@boxyhq/saml-jackson": "1.45.2",
"@boxyhq/saml-jackson": "1.52.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -110,7 +110,7 @@
"otplib": "12.0.1",
"papaparse": "5.5.2",
"posthog-js": "1.240.0",
"posthog-node": "4.17.1",
"posthog-node": "5.9.2",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",

View File

@@ -1,5 +1,5 @@
import { actions } from "@/playwright/utils/mock";
import { Page, expect } from "@playwright/test";
import { actions } from "@/playwright/utils/mock";
import { test } from "./lib/fixtures";
const createNoCodeClickAction = async ({
@@ -170,11 +170,14 @@ const createCodeAction = async ({
await page.getByRole("button", { name: "Create action", exact: true }).click();
const successToast = await page.waitForSelector(".formbricks__toast__success");
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 15000 });
expect(successToast).toBeTruthy();
// Wait for the action to be fully created and committed to the database
await page.waitForLoadState("networkidle", { timeout: 15000 });
const actionButton = page.getByTitle(name);
await expect(actionButton).toBeVisible();
await expect(actionButton).toBeVisible({ timeout: 10000 });
};
const getActionButtonLocator = (page: Page, actionName: string) => {

View File

@@ -0,0 +1,489 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
// Authentication endpoints are hardcoded to avoid import issues
test.describe("Authentication Security Tests - Vulnerability Prevention", () => {
let csrfToken: string;
let testUser: { email: string; password: string };
test.beforeEach(async ({ request, users }) => {
// Get CSRF token for authentication requests
const csrfResponse = await request.get("/api/auth/csrf");
const csrfData = await csrfResponse.json();
csrfToken = csrfData.csrfToken;
// Create a test user for "existing user" scenarios with unique email
const uniqueId = Date.now() + Math.random();
const userName = "Security Test User";
const userEmail = `security-test-${uniqueId}@example.com`;
await users.create({
name: userName,
email: userEmail,
});
testUser = {
email: userEmail,
password: userName, // The fixture uses the name as password
};
});
test.describe("DoS Protection - Password Length Limits", () => {
test("should handle extremely long passwords without crashing", async ({ request }) => {
const email = "nonexistent-dos-test@example.com"; // Use non-existent email for DoS test
const extremelyLongPassword = "A".repeat(50000); // 50,000 characters
const start = Date.now();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: extremelyLongPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseTime = Date.now() - start;
// Should not crash the server (no 500 errors)
expect(response.status()).not.toBe(500);
// Should handle gracefully
expect([200, 400, 401, 429]).toContain(response.status());
logger.info(
`Extremely long password (50k chars) processing time: ${responseTime}ms, status: ${response.status()}`
);
// Verify the security fix is working: long passwords should be rejected quickly
// In production, this should be much faster, but test environment has overhead
if (responseTime < 5000) {
logger.info("✅ Long password rejected quickly - DoS protection working");
} else {
logger.warn("⚠️ Long password took longer than expected - check DoS protection");
}
});
test("should handle password at 128 character limit", async ({ request }) => {
const email = "nonexistent-limit-test@example.com"; // Use non-existent email for limit test
const maxLengthPassword = "A".repeat(128); // Exactly at the 128 character limit
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: maxLengthPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Should process normally (not rejected for length)
expect(response.status()).not.toBe(500);
expect([200, 400, 401, 429]).toContain(response.status());
logger.info(`Max length password (128 chars) status: ${response.status()}`);
});
test("should reject passwords over 128 characters", async ({ request }) => {
const email = "nonexistent-overlimit-test@example.com"; // Use non-existent email for over-limit test
const overLimitPassword = "A".repeat(10000); // 10,000 characters (over limit)
const start = Date.now();
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: email,
password: overLimitPassword,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseTime = Date.now() - start;
// Should not crash
expect(response.status()).not.toBe(500);
logger.info(
`Over-limit password (10k chars) processing time: ${responseTime}ms, status: ${response.status()}`
);
// The key security test: verify it doesn't take exponentially longer than shorter passwords
// This tests the DoS protection is working
});
});
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
test("should not reveal user existence through response timing differences", async ({ request }) => {
// Helper functions for statistical analysis
const calculateMedian = (values: number[]): number => {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
};
const calculateStdDev = (values: number[], mean: number): number => {
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
return Math.sqrt(variance);
};
logger.info("🔥 Phase 1: Warming up caches, DB connections, and JIT compilation...");
// Warm-up phase: Prime caches, database connections, JIT compilation
const warmupAttempts = 10;
for (let i = 0; i < warmupAttempts; i++) {
await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: `warmup-nonexistent-${i}@example.com`,
password: "warmuppassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: testUser.email,
password: "wrongwarmuppassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
}
logger.info("✅ Warm-up complete. Starting actual measurements with 100 attempts per scenario...");
// Actual measurement phase with increased sample size
const attempts = 100;
const nonExistentTimes: number[] = [];
const existingUserTimes: number[] = [];
// Interleave tests to reduce impact of system load variations
for (let i = 0; i < attempts; i++) {
// Test non-existent user
const startNonExistent = process.hrtime.bigint();
const responseNonExistent = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: `nonexistent-timing-${i}@example.com`,
password: "somepassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const endNonExistent = process.hrtime.bigint();
const responseTimeNonExistent = Number(endNonExistent - startNonExistent) / 1000000;
nonExistentTimes.push(responseTimeNonExistent);
expect(responseNonExistent.status()).not.toBe(500);
// Test existing user (interleaved)
const startExisting = process.hrtime.bigint();
const responseExisting = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: testUser.email,
password: "wrongpassword123",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const endExisting = process.hrtime.bigint();
const responseTimeExisting = Number(endExisting - startExisting) / 1000000;
existingUserTimes.push(responseTimeExisting);
expect(responseExisting.status()).not.toBe(500);
}
// Calculate statistics using median (more robust to outliers)
const medianNonExistent = calculateMedian(nonExistentTimes);
const medianExisting = calculateMedian(existingUserTimes);
// Also calculate means for comparison
const avgNonExistent = nonExistentTimes.reduce((a, b) => a + b, 0) / nonExistentTimes.length;
const avgExisting = existingUserTimes.reduce((a, b) => a + b, 0) / existingUserTimes.length;
// Calculate standard deviations
const stdDevNonExistent = calculateStdDev(nonExistentTimes, avgNonExistent);
const stdDevExisting = calculateStdDev(existingUserTimes, avgExisting);
// Calculate timing difference using MEDIAN (more reliable)
const timingDifference = Math.abs(medianExisting - medianNonExistent);
const timingDifferencePercent = (timingDifference / Math.max(medianExisting, medianNonExistent)) * 100;
// Calculate coefficient of variation (CV) for reliability assessment
// CV = (StdDev / Mean) * 100 - measures relative variability
const cvNonExistent = (stdDevNonExistent / avgNonExistent) * 100;
const cvExisting = (stdDevExisting / avgExisting) * 100;
// Log comprehensive statistics
logger.info("📊 Statistical Analysis:");
logger.info(
`Non-existent user - Mean: ${avgNonExistent.toFixed(2)}ms, Median: ${medianNonExistent.toFixed(2)}ms, StdDev: ${stdDevNonExistent.toFixed(2)}ms (CV: ${cvNonExistent.toFixed(1)}%)`
);
logger.info(
`Existing user - Mean: ${avgExisting.toFixed(2)}ms, Median: ${medianExisting.toFixed(2)}ms, StdDev: ${stdDevExisting.toFixed(2)}ms (CV: ${cvExisting.toFixed(1)}%)`
);
logger.info(
`Timing difference (median-based): ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
);
// CRITICAL SECURITY TEST: Timing difference should be minimal
// A large timing difference could allow attackers to enumerate users
// Allow up to 20% difference to account for network/system variance
if (timingDifferencePercent > 20) {
logger.warn(
`⚠️ SECURITY RISK: Timing difference of ${timingDifferencePercent.toFixed(1)}% could allow user enumeration!`
);
logger.warn(`⚠️ Consider implementing constant-time authentication to prevent timing attacks`);
} else {
logger.info(
`✅ Timing attack protection: Only ${timingDifferencePercent.toFixed(1)}% difference between scenarios`
);
}
// Fail the test if timing difference exceeds our security threshold
// Note: This uses MEDIAN-based comparison (more robust to outliers than mean)
expect(timingDifferencePercent).toBeLessThan(20);
// Validate measurement reliability using coefficient of variation (CV)
// CV > 50% indicates high variability and unreliable measurements
const maxAcceptableCV = 50; // 50% is reasonable for network-based tests
if (cvNonExistent > maxAcceptableCV) {
logger.warn(
`⚠️ High variability in non-existent user timing (CV: ${cvNonExistent.toFixed(1)}%). ` +
`Test measurements may be unreliable. Consider increasing warm-up or checking CI environment.`
);
}
if (cvExisting > maxAcceptableCV) {
logger.warn(
`⚠️ High variability in existing user timing (CV: ${cvExisting.toFixed(1)}%). ` +
`Test measurements may be unreliable. Consider increasing warm-up or checking CI environment.`
);
}
// These are soft checks - we warn but don't fail the test for high CV
// This allows for noisy CI environments while still alerting to potential issues
if (cvNonExistent <= maxAcceptableCV && cvExisting <= maxAcceptableCV) {
logger.info(
`✅ Measurement reliability good: CV ${cvNonExistent.toFixed(1)}% and ${cvExisting.toFixed(1)}% (threshold: ${maxAcceptableCV}%)`
);
}
});
test("should return consistent status codes regardless of user existence", async ({ request }) => {
const scenarios = [
{
email: "nonexistent-status@example.com",
password: "testpassword",
description: "non-existent user",
},
{ email: testUser.email, password: "wrongpassword", description: "existing user, wrong password" },
];
const results: { scenario: string; status: number }[] = [];
for (const scenario of scenarios) {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: scenario.email,
password: scenario.password,
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
results.push({
scenario: scenario.description,
status: response.status(),
});
expect(response.status()).not.toBe(500);
}
// Log results
results.forEach(({ scenario, status }) => {
logger.info(`Status test - ${scenario}: ${status}`);
});
// CRITICAL: Both scenarios should return the same status code
// Different status codes could reveal user existence
const statuses = results.map((r) => r.status);
const uniqueStatuses = [...new Set(statuses)];
if (uniqueStatuses.length > 1) {
logger.warn(
`⚠️ SECURITY RISK: Different status codes (${uniqueStatuses.join(", ")}) could allow user enumeration!`
);
} else {
logger.info(`✅ Status code consistency: Both scenarios return ${statuses[0]}`);
}
expect(uniqueStatuses.length).toBe(1);
});
});
test.describe("Security Headers and Response Safety", () => {
test("should include security headers in responses", async ({ request }) => {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-headers-test@example.com",
password: "testpassword",
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Check for important security headers
const headers = response.headers();
// These headers should be present for security
expect(headers["x-frame-options"]).toBeDefined();
expect(headers["x-content-type-options"]).toBe("nosniff");
if (headers["strict-transport-security"]) {
expect(headers["strict-transport-security"]).toContain("max-age");
}
if (headers["content-security-policy"]) {
expect(headers["content-security-policy"]).toContain("default-src");
}
logger.info("✅ Security headers present in authentication responses");
});
test("should not expose sensitive information in error responses", async ({ request }) => {
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-disclosure-test@example.com",
password: "A".repeat(10000), // Trigger long password handling
redirect: "false",
csrfToken: csrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const responseBody = await response.text();
// Log the actual response for debugging
logger.info(`Response status: ${response.status()}`);
logger.info(`Response body (first 500 chars): ${responseBody.substring(0, 500)}`);
// Check if this is an HTML response (which indicates NextAuth.js is returning a page instead of API response)
const isHtmlResponse =
responseBody.trim().startsWith("<!DOCTYPE html>") || responseBody.includes("<html");
if (isHtmlResponse) {
logger.info(
"✅ NextAuth.js returned HTML page instead of API response - this is expected behavior for security"
);
logger.info("✅ No sensitive technical information exposed in authentication API");
return; // Skip the sensitive information check for HTML responses
}
// Only check for sensitive information in actual API responses (JSON/text)
const sensitiveTerms = [
"password_too_long",
"bcrypt",
"hash",
"redis",
"database",
"prisma",
"stack trace",
"rate limit exceeded",
"authentication failed",
"sql",
"query",
"connection timeout",
"internal error",
];
let foundSensitiveInfo = false;
const foundTerms: string[] = [];
for (const term of sensitiveTerms) {
if (responseBody.toLowerCase().includes(term.toLowerCase())) {
foundSensitiveInfo = true;
foundTerms.push(term);
logger.warn(`Found "${term}" in response`);
}
}
if (foundSensitiveInfo) {
logger.warn(`⚠️ Found sensitive information in response: ${foundTerms.join(", ")}`);
logger.warn(`Full response body: ${responseBody}`);
} else {
logger.info("✅ No sensitive technical information exposed in error responses");
}
// Don't fail the test for generic web responses, only for actual security leaks
expect(foundSensitiveInfo).toBe(false);
});
test("should handle malformed requests gracefully", async ({ request }) => {
// Test with missing CSRF token
const response = await request.post("/api/auth/callback/credentials", {
data: {
callbackUrl: "",
email: "nonexistent-malformed-test@example.com",
password: "testpassword",
redirect: "false",
json: "true",
// Missing csrfToken
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// Should handle gracefully, not crash
expect(response.status()).not.toBe(500);
expect([200, 400, 401, 403, 429]).toContain(response.status());
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
});
});

View File

@@ -3,6 +3,11 @@ export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const HEALTH_API_URL = `/api/v2/health`;
// Authentication endpoints
export const AUTH_CALLBACK_URL = `/api/auth/callback/credentials`;
export const AUTH_CSRF_URL = `/api/auth/csrf`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>

View File

@@ -0,0 +1,135 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../lib/fixtures";
import { HEALTH_API_URL } from "./constants";
test.describe("API Tests for Health Endpoint", () => {
test("Health check returns 200 with dependency status", async ({ request }) => {
try {
// Make request to health endpoint (no authentication required)
const response = await request.get(HEALTH_API_URL);
// Should always return 200 if the health check endpoint can execute
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify response structure
expect(responseBody).toHaveProperty("data");
expect(responseBody.data).toHaveProperty("main_database");
expect(responseBody.data).toHaveProperty("cache_database");
// Verify data types are boolean
expect(typeof responseBody.data.main_database).toBe("boolean");
expect(typeof responseBody.data.cache_database).toBe("boolean");
// Log the health status for debugging
logger.info(
{
main_database: responseBody.data.main_database,
cache_database: responseBody.data.cache_database,
},
"Health check status"
);
// In a healthy system, we expect both to be true
// But we don't fail the test if they're false - that's what the health check is for
if (!responseBody.data.main_database) {
logger.warn("Main database is unhealthy");
}
if (!responseBody.data.cache_database) {
logger.warn("Cache database is unhealthy");
}
} catch (error) {
logger.error(error, "Error during health check API test");
throw error;
}
});
test("Health check response time is reasonable", async ({ request }) => {
try {
const startTime = Date.now();
const response = await request.get(HEALTH_API_URL);
const endTime = Date.now();
const responseTime = endTime - startTime;
expect(response.status()).toBe(200);
// Health check should respond within 5 seconds
expect(responseTime).toBeLessThan(5000);
logger.info({ responseTime }, "Health check response time");
} catch (error) {
logger.error(error, "Error during health check performance test");
throw error;
}
});
test("Health check is accessible without authentication", async ({ request }) => {
try {
// Make request without any headers or authentication
const response = await request.get(HEALTH_API_URL, {
headers: {
// Explicitly no x-api-key or other auth headers
},
});
// Should be accessible without authentication
expect(response.status()).toBe(200);
const responseBody = await response.json();
expect(responseBody).toHaveProperty("data");
} catch (error) {
logger.error(error, "Error during unauthenticated health check test");
throw error;
}
});
test("Health check handles CORS properly", async ({ request }) => {
try {
// Test with OPTIONS request (preflight)
const optionsResponse = await request.fetch(HEALTH_API_URL, {
method: "OPTIONS",
});
// OPTIONS should succeed or at least not be a server error
expect(optionsResponse.status()).not.toBe(500);
// Test regular GET request
const getResponse = await request.get(HEALTH_API_URL);
expect(getResponse.status()).toBe(200);
} catch (error) {
logger.error(error, "Error during CORS health check test");
throw error;
}
});
test("Health check OpenAPI schema compliance", async ({ request }) => {
try {
const response = await request.get(HEALTH_API_URL);
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify it matches our OpenAPI schema
expect(responseBody).toMatchObject({
data: {
main_database: expect.any(Boolean),
cache_database: expect.any(Boolean),
},
});
// Ensure no extra properties in the response data
const dataKeys = Object.keys(responseBody.data);
expect(dataKeys).toHaveLength(2);
expect(dataKeys).toContain("main_database");
expect(dataKeys).toContain("cache_database");
} catch (error) {
logger.error(error, "Error during OpenAPI schema compliance test");
throw error;
}
});
});

View File

@@ -72,6 +72,7 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixtu
name: uname,
email: userEmail,
password: hashedPassword,
emailVerified: new Date(),
locale: "en-US",
memberships: {
create: {

View File

@@ -5,7 +5,7 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await page.waitForURL(/\/environments\/[^/]+\/surveys/, { timeout: 30000 });
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
@@ -13,9 +13,9 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
throw new Error("Unable to parse environmentId from URL");
})();
await page.goto(`/environments/${environmentId}/settings/api-keys`);
await page.goto(`/environments/${environmentId}/settings/api-keys`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add API Key" }).isVisible();
await page.getByRole("button", { name: "Add API Key" }).waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("button", { name: "Add API Key" }).click();
await page.getByPlaceholder("e.g. GitHub, PostHog, Slack").fill("E2E Test API Key");
await page.getByRole("button", { name: "+ Add permission" }).click();
@@ -26,7 +26,18 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
await page.getByTestId("organization-access-accessControl-read").click();
await page.getByTestId("organization-access-accessControl-write").click();
await page.getByRole("button", { name: "Add API Key" }).click();
await page.locator(".copyApiKeyIcon").click();
// Wait for the API key creation to complete and appear in the list
// Use longer timeouts for cloud environments with high concurrency and network latency
// Wait for network idle to ensure the API key is fully committed to the database
await page.waitForLoadState("networkidle", { timeout: 30000 });
await page.waitForSelector(".copyApiKeyIcon", { state: "visible", timeout: 30000 });
// Add a delay to ensure the API key is fully committed to the database
// This is especially important with high concurrency in cloud environments
await page.waitForTimeout(2000);
await page.locator(".copyApiKeyIcon").first().click();
const apiKey = await page.evaluate("navigator.clipboard.readText()");

View File

@@ -1,5 +1,5 @@
import { surveys } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { surveys } from "@/playwright/utils/mock";
import { test } from "./lib/fixtures";
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
@@ -28,10 +28,13 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
// Get URL
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
// Get URL - increase timeout for slower local environments
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
await page.getByLabel("Copy survey link to clipboard").click();
url = await page.evaluate("navigator.clipboard.readText()");
});
@@ -291,7 +294,7 @@ test.describe("Multi Language Survey Create", async () => {
.filter({ hasText: /^Add questionAdd a new question to your survey$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByLabel("Question*").fill(surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
@@ -446,7 +449,7 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByPlaceholder("Back").fill(surveys.germanCreate.back);
// Fill Multi select question in german
await page.getByRole("main").getByText("Multi-Select").click();
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
await page.getByPlaceholder("Your question here. Recall").click();
await page
@@ -625,10 +628,13 @@ test.describe("Multi Language Survey Create", async () => {
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForTimeout(5000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/);
await page.waitForTimeout(2000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
await page.getByLabel("Select Language").click();
await page.getByText("German").click();
await page.getByLabel("Copy survey link to clipboard").click();

View File

@@ -1,9 +1,9 @@
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
import { expect } from "@playwright/test";
import { readFileSync, writeFileSync } from "fs";
import { Page } from "playwright";
import { logger } from "@formbricks/logger";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
export const signUpAndLogin = async (
page: Page,
@@ -203,7 +203,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description", exact: true }).click();
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);
@@ -416,7 +416,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
.filter({ hasText: new RegExp(`^${addQuestion}$`) })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select" }).click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await page.getByLabel("Question*").fill(params.multiSelectQuestion.question);
await page.getByRole("button", { name: "Add description" }).click();
await page.locator('input[name="subheader"]').fill(params.multiSelectQuestion.description);

View File

@@ -15,7 +15,7 @@ Before you proceed, make sure you have the following:
Copy and paste the following command into your terminal:
```bash
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/formbricks.sh)"
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/formbricks.sh)"
```
The script will prompt you for the following information:

0
docker/migrate-to-v4.sh Normal file → Executable file
View File

View File

@@ -5675,7 +5675,7 @@
},
"/api/v1/management/storage": {
"post": {
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3 along with a local upload URL.",
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3.",
"parameters": [
{
"example": "{{apiKey}}",
@@ -5732,8 +5732,15 @@
"example": {
"data": {
"fileUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile--fid--abc123.png",
"localUrl": "http://localhost:3000/storage/cm1ubebtj000614kqe4hs3c67/public/profile.png",
"signedUrl": "http://localhost:3000/api/v1/client/cm1ubebtj000614kqe4hs3c67/storage/public",
"presignedFields": {
"Policy": "base64EncodedPolicy",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "your-credential",
"X-Amz-Date": "20250312T000000Z",
"X-Amz-Signature": "your-signature",
"key": "uploads/public/profile--fid--abc123.png"
},
"signedUrl": "https://s3.example.com/your-bucket",
"updatedFileName": "profile--fid--abc123.png"
}
},
@@ -5745,9 +5752,12 @@
"description": "URL where the uploaded file can be accessed.",
"type": "string"
},
"localUrl": {
"description": "URL for uploading the file to local storage.",
"type": "string"
"presignedFields": {
"additionalProperties": {
"type": "string"
},
"description": "Form fields to include in the multipart/form-data POST to S3.",
"type": "object"
},
"signedUrl": {
"description": "Signed URL for uploading the file to S3.",
@@ -5765,7 +5775,7 @@
}
}
},
"description": "OK - Returns the signed URL, updated file name, and file URL."
"description": "OK - Returns the signed URL, presigned fields, updated file name, and file URL."
},
"400": {
"content": {
@@ -5829,187 +5839,6 @@
"tags": ["Management API - Storage"]
}
},
"/api/v1/management/storage/local": {
"post": {
"description": "Management API endpoint for uploading public files to local storage. This endpoint requires authentication. File metadata is provided via headers (X-File-Type, X-File-Name, X-Environment-ID, X-Signature, X-UUID, X-Timestamp) and the file is provided as a multipart/form-data file field named \"file\". The \"Content-Type\" header must be set to a valid MIME type.",
"parameters": [
{
"example": "{{apiKey}}",
"in": "header",
"name": "x-api-key",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "MIME type of the file. Must be a valid MIME type.",
"in": "header",
"name": "X-File-Type",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "URI encoded file name.",
"in": "header",
"name": "X-File-Name",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "ID of the environment.",
"in": "header",
"name": "X-Environment-ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Signature for verifying the request.",
"in": "header",
"name": "X-Signature",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Unique identifier for the signed upload.",
"in": "header",
"name": "X-UUID",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Timestamp used for the signature.",
"in": "header",
"name": "X-Timestamp",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The file to be uploaded as a valid file object (buffer).",
"format": "binary",
"type": "string"
}
},
"required": ["file"],
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {
"message": "File uploaded successfully"
}
},
"schema": {
"properties": {
"data": {
"properties": {
"message": {
"description": "Success message.",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": "OK - File uploaded successfully."
},
"400": {
"content": {
"application/json": {
"example": {
"error": "fileType is required"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Bad Request - Missing required fields, invalid header values, or file issues."
},
"401": {
"content": {
"application/json": {
"example": {
"error": "Not authenticated"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Unauthorized - Authentication failed, invalid signature, or user not authorized."
},
"500": {
"content": {
"application/json": {
"example": {
"error": "File upload failed"
},
"schema": {
"properties": {
"error": {
"description": "Detailed error message.",
"type": "string"
}
},
"type": "object"
}
}
},
"description": "Internal Server Error - File upload failed due to server error."
}
},
"servers": [
{
"description": "Formbricks API Server",
"url": "https://app.formbricks.com/api/v1"
}
],
"summary": "Upload Public File to Local Storage",
"tags": ["Management API - Storage"]
}
},
"/api/v1/management/surveys": {
"get": {
"description": "Fetches all existing surveys",

View File

@@ -7,6 +7,8 @@ servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks Cloud
tags:
- name: Health
description: Operations for checking critical application dependencies health status.
- name: Roles
description: Operations for managing roles.
- name: Me
@@ -391,6 +393,36 @@ paths:
servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/health:
get:
tags:
- Health
summary: Health Check
description: Check the health status of critical application dependencies
including database and cache.
operationId: healthCheck
security: []
responses:
"200":
description: Health check completed successfully. Check individual dependency
status in response data.
content:
application/json:
schema:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
title: Health Check Response
description: Health check status for critical application dependencies
/roles:
get:
operationId: getRoles
@@ -1985,6 +2017,17 @@ paths:
type: string
description: The ID of the contact
required: true
- in: query
name: expirationDays
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
schema:
type:
- number
- undefined
minimum: 1
description: Number of days until the generated JWT expires. If not provided,
there is no expiration.
responses:
"200":
description: Personalized survey link retrieved successfully.
@@ -1999,6 +2042,11 @@ paths:
surveyUrl:
type: string
format: uri
expiresAt:
type: string
format: date-time
nullable: true
description: The date and time the link expires, null if no expiration
required:
- surveyUrl
/surveys/{surveyId}/contact-links/segments/{segmentId}:
@@ -3500,6 +3548,24 @@ components:
name: x-api-key
description: Use your Formbricks x-api-key to authenticate.
schemas:
health:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
required:
- main_database
- cache_database
title: Health Check Response
description: Health check status for critical application dependencies
role:
type: object
properties:
@@ -3835,8 +3901,6 @@ components:
type: string
enum:
- link
- web
- website
- app
description: The type of the survey
status:
@@ -4346,7 +4410,6 @@ components:
- createdBy
- environmentId
- endings
- thankYouCard
- hiddenFields
- variables
- displayOption
@@ -4364,7 +4427,6 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- metadata
- displayPercentage

View File

@@ -5,6 +5,11 @@
"light": "#00C4B8",
"primary": "#00C4B8"
},
"errors": {
"404": {
"redirect": true
}
},
"favicon": "/images/favicon.svg",
"footer": {
"socials": {
@@ -69,13 +74,13 @@
"xm-and-surveys/surveys/general-features/multi-language-surveys",
"xm-and-surveys/surveys/general-features/partial-submissions",
"xm-and-surveys/surveys/general-features/recall",
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"xm-and-surveys/surveys/general-features/metadata",
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button",
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection"
"xm-and-surveys/surveys/general-features/spam-protection",
"xm-and-surveys/surveys/general-features/tags"
]
},
{
@@ -225,6 +230,7 @@
"self-hosting/configuration/custom-ssl",
"self-hosting/configuration/environment-variables",
"self-hosting/configuration/smtp",
"self-hosting/configuration/file-uploads",
"self-hosting/configuration/domain-configuration",
{
"group": "Auth & SSO",
@@ -393,447 +399,358 @@
"redirects": [
{
"destination": "/docs/overview/what-is-formbricks",
"permanent": true,
"source": "/docs/introduction/what-is-formbricks"
},
{
"destination": "/docs/overview/open-source",
"permanent": true,
"source": "/docs/introduction/why-open-source"
},
{
"destination": "/docs/xm-and-surveys/overview",
"permanent": true,
"source": "/docs/introduction/how-it-works"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/contact-form",
"permanent": true,
"source": "/docs/best-practices/contact-form"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/docs-feedback",
"permanent": true,
"source": "/docs/best-practices/docs-feedback"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feature-chaser",
"permanent": true,
"source": "/docs/best-practices/feature-chaser"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/feedback-box",
"permanent": true,
"source": "/docs/best-practices/feedback-box"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-email-content",
"permanent": true,
"source": "/docs/best-practices/improve-email-content"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/interview-prompt",
"permanent": true,
"source": "/docs/best-practices/interview-prompt"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/cancel-subscription",
"permanent": true,
"source": "/docs/best-practices/cancel-subscription"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/pmf-survey",
"permanent": true,
"source": "/docs/best-practices/pmf-survey"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/quiz-time",
"permanent": true,
"source": "/docs/best-practices/quiz-time"
},
{
"destination": "/docs/xm-and-surveys/xm/best-practices/improve-trial-cr",
"permanent": true,
"source": "/docs/best-practices/improve-trial-cr"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/quickstart",
"permanent": true,
"source": "/docs/link-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/link-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/conditional-logic",
"permanent": true,
"source": "/docs/link-surveys/global/conditional-logic"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/overwrite-styling",
"permanent": true,
"source": "/docs/link-surveys/global/overwrite-styling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/global/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/embed-surveys",
"permanent": true,
"source": "/docs/link-surveys/embed-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/link-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/market-research-panel",
"permanent": true,
"source": "/docs/link-surveys/market-research-panel"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/link-surveys/global/multi-language-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/partial-submissions",
"permanent": true,
"source": "/docs/link-surveys/global/partial-submissions"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"permanent": true,
"source": "/docs/link-surveys/pin-protected-surveys"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/recall",
"permanent": true,
"source": "/docs/link-surveys/global/recall"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
"permanent": true,
"source": "/docs/link-surveys/single-use-links"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
"permanent": true,
"source": "/docs/link-surveys/source-tracking"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/schedule-start-end-dates",
"permanent": true,
"source": "/docs/link-surveys/global/schedule-start-end-dates"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
"permanent": true,
"source": "/docs/link-surveys/start-at-question"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/link-surveys/global/metadata"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/variables",
"permanent": true,
"source": "/docs/link-surveys/global/variables"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"permanent": true,
"source": "/docs/link-surveys/verify-email-before-survey"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/add-image-or-video-question",
"permanent": true,
"source": "/docs/app-surveys/global/add-image-or-video-question"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/consent",
"permanent": true,
"source": "/docs/core-features/global/question-type/consent"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/statement-cta",
"permanent": true,
"source": "/docs/core-features/global/question-type/statement-cta"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/airtable",
"permanent": true,
"source": "/docs/developer-docs/integrations/airtable"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/zapier",
"permanent": true,
"source": "/docs/developer-docs/integrations/zapier"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/wordpress",
"permanent": true,
"source": "/docs/developer-docs/integrations/wordpress"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/slack",
"permanent": true,
"source": "/docs/developer-docs/integrations/slack"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/n8n",
"permanent": true,
"source": "/docs/developer-docs/integrations/n8n"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/notion",
"permanent": true,
"source": "/docs/developer-docs/integrations/notion"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/google-sheets",
"permanent": true,
"source": "/docs/developer-docs/integrations/google-sheets"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/quickstart",
"permanent": true,
"source": "/docs/app-surveys/quickstart"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/address",
"permanent": true,
"source": "/docs/core-features/global/question-type/address"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/app-surveys/framework-guides"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/activepieces",
"permanent": true,
"source": "/docs/developer-docs/integrations/activepieces"
},
{
"destination": "/docs/xm-and-surveys/core-features/user-management",
"permanent": true,
"source": "/docs/core-features/global/access-roles"
},
{
"destination": "/docs/xm-and-surveys/core-features/styling-theme",
"permanent": true,
"source": "/docs/core-features/global/styling-theme"
},
{
"destination": "/docs/xm-and-surveys/core-features/email-customization",
"permanent": true,
"source": "/docs/core-features/global/email-customization"
},
{
"destination": "/docs/self-hosting/setup/one-click",
"permanent": true,
"source": "/docs/self-hosting/one-click"
},
{
"destination": "/docs/self-hosting/configuration/custom-ssl",
"permanent": true,
"source": "/docs/self-hosting/custom-ssl"
},
{
"destination": "/docs/self-hosting/setup/docker",
"permanent": true,
"source": "/docs/self-hosting/docker"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/cluster-setup"
},
{
"destination": "/docs/self-hosting/advanced/migration",
"permanent": true,
"source": "/docs/self-hosting/migration-guide"
},
{
"destination": "/docs/self-hosting/configuration/integrations",
"permanent": true,
"source": "/docs/self-hosting/integrations"
},
{
"destination": "/docs/self-hosting/advanced/license",
"permanent": true,
"source": "/docs/self-hosting/license"
},
{
"destination": "/docs/self-hosting/advanced/rate-limiting",
"permanent": true,
"source": "/docs/self-hosting/rate-limiting"
},
{
"destination": "/docs/self-hosting/setup/cluster-setup",
"permanent": true,
"source": "/docs/self-hosting/kubernetes"
},
{
"destination": "/docs/development/overview",
"permanent": true,
"source": "/docs/developer-docs/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides",
"permanent": true,
"source": "/docs/developer-docs/js-sdk"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
"permanent": true,
"source": "/docs/developer-docs/react-native-in-app-surveys"
},
{
"destination": "/docs/api-reference/rest-api",
"permanent": true,
"source": "/docs/developer-docs/rest-api"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/webhooks",
"permanent": true,
"source": "/docs/developer-docs/webhooks"
},
{
"destination": "/docs/development/contribution/contribution",
"permanent": true,
"source": "/docs/developer-docs/contributing/get-started"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/actions",
"permanent": true,
"source": "/docs/app-surveys/actions"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
"permanent": true,
"source": "/docs/app-surveys/advanced-targeting"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/user-identification",
"permanent": true,
"source": "/docs/app-surveys/user-identification"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
"permanent": true,
"source": "/docs/app-surveys/recontact"
},
{
"destination": "/docs/xm-and-surveys/surveys/website-app-surveys/show-survey-to-percent-of-users",
"permanent": true,
"source": "/docs/app-surveys/global/show-survey-to-percent-of-users"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/metadata",
"permanent": true,
"source": "/docs/app-surveys/global/metadata"
},
{
"destination": "/docs/api-reference",
"permanent": true,
"source": "/docs/api-docs"
},
{
"destination": "/docs/development/troubleshooting",
"permanent": true,
"source": "/docs/developer-docs/contributing/troubleshooting"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/file-upload",
"permanent": true,
"source": "/docs/core-features/global/question-type/file-upload"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-picture",
"permanent": true,
"source": "/docs/core-features/global/question-type/picture-selection"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/rating",
"permanent": true,
"source": "/docs/core-features/global/question-type/rating"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/date",
"permanent": true,
"source": "/docs/core-features/global/question-type/date"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/schedule-a-meeting",
"permanent": true,
"source": "/docs/core-features/global/question-type/schedule"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/free-text",
"permanent": true,
"source": "/docs/core-features/global/question-type/free-text"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-single",
"permanent": true,
"source": "/docs/core-features/global/question-type/single-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/select-multiple",
"permanent": true,
"source": "/docs/core-features/global/question-type/multiple-select"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/matrix",
"permanent": true,
"source": "/docs/core-features/global/question-type/matrix"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/make",
"permanent": true,
"source": "/docs/developer-docs/integrations/make"
},
{
"destination": "/docs/xm-and-surveys/core-features/integrations/overview",
"permanent": true,
"source": "/docs/developer-docs/integrations/overview"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/hidden-fields",
"permanent": true,
"source": "/docs/app-surveys/global/hidden-fields"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/limit-submissions",
"permanent": true,
"source": "/docs/app-surveys/global/limit-submissions"
},
{
"destination": "/docs/xm-and-surveys/core-features/question-type/net-promoter-score",
"permanent": true,
"source": "/docs/core-features/global/question-type/net-promoter-score"
},
{
"destination": "/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
"permanent": true,
"source": "/docs/link-surveys/data-prefilling"
},
{
"destination": "/docs/xm-and-surveys/surveys/general-features/multi-language-surveys",
"permanent": true,
"source": "/docs/app-surveys/global/multi-language-surveys"
}
],

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