mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 22:50:35 -06:00
Compare commits
46 Commits
4.0.1
...
fix/upgrad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca55d6bbc0 | ||
|
|
3f122ed9ee | ||
|
|
bdad80d6d1 | ||
|
|
d9ea00d86e | ||
|
|
4a3c2fccba | ||
|
|
3a09af674a | ||
|
|
1ced76c44d | ||
|
|
fa1663d858 | ||
|
|
ebf591a7e0 | ||
|
|
5c9795cd23 | ||
|
|
b67177ba55 | ||
|
|
6cf1f49c8e | ||
|
|
4afb95b92a | ||
|
|
38089241b4 | ||
|
|
07487d4871 | ||
|
|
fa0879e3a0 | ||
|
|
3733c22a6f | ||
|
|
5e5baa76ab | ||
|
|
2153d2aa16 | ||
|
|
7fa4862fd9 | ||
|
|
411e9a26ee | ||
|
|
eb1349f205 | ||
|
|
5c25f25212 | ||
|
|
6af81e46ee | ||
|
|
7423fc9472 | ||
|
|
1557ffcca1 | ||
|
|
5d53ed76ed | ||
|
|
ebd399e611 | ||
|
|
843110b0d6 | ||
|
|
51babf2f98 | ||
|
|
6bc5f1e168 | ||
|
|
c9016802e7 | ||
|
|
6a49fb4700 | ||
|
|
646921cd37 | ||
|
|
34d3145fcd | ||
|
|
c3c06eb309 | ||
|
|
bf4c6238d5 | ||
|
|
8972ef0fef | ||
|
|
4e59924a5a | ||
|
|
8b28353b79 | ||
|
|
abbc7a065b | ||
|
|
00e8ee27a2 | ||
|
|
379aeba71a | ||
|
|
717adddeae | ||
|
|
41798266a0 | ||
|
|
a93fa8ec76 |
179
.cursor/rules/review-and-refine.mdc
Normal file
179
.cursor/rules/review-and-refine.mdc
Normal 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.**
|
||||
19
.github/workflows/e2e.yml
vendored
19
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
1
apps/web/app/api/v2/health/route.ts
Normal file
1
apps/web/app/api/v2/health/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET } from "@/modules/api/v2/health/route";
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 )"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal file
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal file
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal file
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
15
apps/web/modules/api/v2/health/route.ts
Normal file
15
apps/web/modules/api/v2/health/route.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal file
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal 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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const calculateExpirationDate = (expirationDays: number) => {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + expirationDays);
|
||||
return expirationDate.toISOString();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,7 +72,6 @@ describe("MultipleChoiceQuestionForm", () => {
|
||||
selectedLanguageCode="default"
|
||||
setSelectedLanguageCode={vi.fn()}
|
||||
locale="en-US"
|
||||
lastQuestion={false}
|
||||
isStorageConfigured={true}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
489
apps/web/playwright/api/auth/security.spec.ts
Normal file
489
apps/web/playwright/api/auth/security.spec.ts
Normal 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()}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
135
apps/web/playwright/api/health.spec.ts
Normal file
135
apps/web/playwright/api/health.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
0
docker/migrate-to-v4.sh
Normal file → Executable 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user