mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-07 19:30:07 -05:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce3bae78b | |||
| 6e19de32f7 | |||
| 957a4432f4 | |||
| 22a5d4bb7d | |||
| 226dff0344 | |||
| d474a94a21 | |||
| c1a4cc308b | |||
| 210da98b69 | |||
| 2fc183d384 | |||
| 78fb111610 | |||
| 11c0cb4b61 | |||
| 95831f7c7f | |||
| a31e7bfaa5 | |||
| 6e35fc1769 | |||
| 48cded1646 | |||
| db752cee15 | |||
| b33aae0a73 | |||
| 72126ad736 | |||
| 4a2eeac90b | |||
| 46be3e7d70 | |||
| 6d140532a7 | |||
| 8c4a7f1518 | |||
| 63fe32a786 | |||
| 84c465f974 | |||
| 6a33498737 | |||
| 5130c747d4 | |||
| f5583d2652 | |||
| e0d75914a4 | |||
| f02ca1cfe1 | |||
| 4ade83f189 | |||
| f1fc9fea2c | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af |
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Build & Deployment Best Practices
|
|
||||||
|
|
||||||
## Build Process
|
|
||||||
|
|
||||||
### Running Builds
|
|
||||||
- Use `pnpm build` from project root for full build
|
|
||||||
- Monitor for React hooks warnings and fix them immediately
|
|
||||||
- Ensure all TypeScript errors are resolved before deployment
|
|
||||||
|
|
||||||
### Common Build Issues & Fixes
|
|
||||||
|
|
||||||
#### React Hooks Warnings
|
|
||||||
- Capture ref values in variables within useEffect cleanup
|
|
||||||
- Avoid accessing `.current` directly in cleanup functions
|
|
||||||
- Pattern for fixing ref cleanup warnings:
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
const currentRef = myRef.current;
|
|
||||||
return () => {
|
|
||||||
if (currentRef) {
|
|
||||||
currentRef.cleanup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Test Failures During Build
|
|
||||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
|
||||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
|
||||||
- Remove unused imports and constants from test files
|
|
||||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
|
||||||
|
|
||||||
### Test Execution
|
|
||||||
- Run `pnpm test` to execute all tests
|
|
||||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
|
||||||
- Fix test failures before merging code
|
|
||||||
- Ensure 100% test coverage for new components
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
- Monitor build times and optimize if necessary
|
|
||||||
- Watch for memory usage during builds
|
|
||||||
- Use proper caching strategies for faster rebuilds
|
|
||||||
|
|
||||||
### Deployment Checklist
|
|
||||||
1. All tests passing
|
|
||||||
2. Build completes without warnings
|
|
||||||
3. TypeScript compilation successful
|
|
||||||
4. No linter errors
|
|
||||||
5. Database migrations applied (if any)
|
|
||||||
6. Environment variables configured
|
|
||||||
|
|
||||||
### EKS Deployment Considerations
|
|
||||||
- Ensure latest code is deployed to all pods
|
|
||||||
- Monitor AWS RDS Performance Insights for database issues
|
|
||||||
- Verify environment-specific configurations
|
|
||||||
- Check pod health and resource usage
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
---
|
|
||||||
description: Caching rules for performance improvements
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Cache Optimization Patterns for Formbricks
|
|
||||||
|
|
||||||
## Cache Strategy Overview
|
|
||||||
|
|
||||||
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
|
||||||
|
|
||||||
- **Redis** for persistent cross-request caching
|
|
||||||
- **React `cache()`** for request-level deduplication
|
|
||||||
- **NO Next.js `unstable_cache()`** - avoid for reliability
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
### Core Cache Infrastructure
|
|
||||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
|
||||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
|
||||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
|
||||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
|
||||||
|
|
||||||
### Environment State Caching (Critical Endpoint)
|
|
||||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
|
||||||
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
|
|
||||||
|
|
||||||
## Enterprise-Grade Cache Key Patterns
|
|
||||||
|
|
||||||
**Always use** the `createCacheKey` utilities from the cache package:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Correct patterns
|
|
||||||
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
|
|
||||||
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
|
|
||||||
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
|
|
||||||
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
|
|
||||||
|
|
||||||
// ❌ Never use flat keys - collision-prone
|
|
||||||
"environment_abc123"
|
|
||||||
"user_data_456"
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to Use Each Cache Type
|
|
||||||
|
|
||||||
### Use React `cache()` for Request Deduplication
|
|
||||||
```typescript
|
|
||||||
// ✅ Prevents multiple calls within same request
|
|
||||||
export const getEnterpriseLicense = reactCache(async () => {
|
|
||||||
// Complex license validation logic
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use `cache.withCache()` for Simple Database Queries
|
|
||||||
```typescript
|
|
||||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
|
||||||
export const getActionClasses = (environmentId: string) => {
|
|
||||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
|
||||||
createCacheKey.environment.actionClasses(environmentId),
|
|
||||||
60 * 30 * 1000 // 30 minutes in milliseconds
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use Explicit Redis Cache for Complex Business Logic
|
|
||||||
```typescript
|
|
||||||
// ✅ Full control for high-stakes endpoints
|
|
||||||
export const getEnvironmentState = async (environmentId: string) => {
|
|
||||||
const cached = await environmentStateCache.getEnvironmentState(environmentId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const fresh = await buildComplexState(environmentId);
|
|
||||||
await environmentStateCache.setEnvironmentState(environmentId, fresh);
|
|
||||||
return fresh;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caching Decision Framework
|
|
||||||
|
|
||||||
### When TO Add Caching
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Expensive operations that benefit from caching
|
|
||||||
- Database queries (>10ms typical)
|
|
||||||
- External API calls (>50ms typical)
|
|
||||||
- Complex computations (>5ms)
|
|
||||||
- File system operations
|
|
||||||
- Heavy data transformations
|
|
||||||
|
|
||||||
// Example: Database query with complex joins (TTL in milliseconds)
|
|
||||||
export const getEnvironmentWithDetails = withCache(
|
|
||||||
async (environmentId: string) => {
|
|
||||||
return prisma.environment.findUnique({
|
|
||||||
where: { id: environmentId },
|
|
||||||
include: { /* complex joins */ }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
|
|
||||||
)();
|
|
||||||
```
|
|
||||||
|
|
||||||
### When NOT to Add Caching
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Don't cache these operations - minimal overhead
|
|
||||||
- Simple property access (<0.1ms)
|
|
||||||
- Basic transformations (<1ms)
|
|
||||||
- Functions that just call already-cached functions
|
|
||||||
- Pure computation without I/O
|
|
||||||
|
|
||||||
// ❌ Bad example: Redundant caching
|
|
||||||
const getCachedLicenseFeatures = withCache(
|
|
||||||
async () => {
|
|
||||||
const license = await getEnterpriseLicense(); // Already cached!
|
|
||||||
return license.active ? license.features : null; // Just property access
|
|
||||||
},
|
|
||||||
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
|
|
||||||
);
|
|
||||||
|
|
||||||
// ✅ Good example: Simple and efficient
|
|
||||||
const getLicenseFeatures = async () => {
|
|
||||||
const license = await getEnterpriseLicense(); // Already cached
|
|
||||||
return license.active ? license.features : null; // 0.1ms overhead
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Computational Overhead Analysis
|
|
||||||
|
|
||||||
Before adding caching, analyze the overhead:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ High overhead - CACHE IT
|
|
||||||
- Database queries: ~10-100ms
|
|
||||||
- External APIs: ~50-500ms
|
|
||||||
- File I/O: ~5-50ms
|
|
||||||
- Complex algorithms: >5ms
|
|
||||||
|
|
||||||
// ❌ Low overhead - DON'T CACHE
|
|
||||||
- Property access: ~0.001ms
|
|
||||||
- Simple lookups: ~0.1ms
|
|
||||||
- Basic validation: ~1ms
|
|
||||||
- Type checks: ~0.01ms
|
|
||||||
|
|
||||||
// Example decision tree:
|
|
||||||
const expensiveOperation = async () => {
|
|
||||||
return prisma.query(); // 50ms - CACHE IT
|
|
||||||
};
|
|
||||||
|
|
||||||
const cheapOperation = (data: any) => {
|
|
||||||
return data.property; // 0.001ms - DON'T CACHE
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avoid Cache Wrapper Anti-Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Don't create wrapper functions just for caching
|
|
||||||
const getCachedUserPermissions = withCache(
|
|
||||||
async (userId: string) => getUserPermissions(userId),
|
|
||||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
|
||||||
);
|
|
||||||
|
|
||||||
// ✅ Add caching directly to the original function
|
|
||||||
export const getUserPermissions = withCache(
|
|
||||||
async (userId: string) => {
|
|
||||||
return prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: { permissions: true }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## TTL Coordination Strategy
|
|
||||||
|
|
||||||
### Multi-Layer Cache Coordination
|
|
||||||
For endpoints serving client SDKs, coordinate TTLs across layers:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
|
||||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
|
||||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
|
||||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
|
||||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
|
||||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
|
|
||||||
```typescript
|
|
||||||
// Configuration data - rarely changes
|
|
||||||
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
|
|
||||||
|
|
||||||
// User data - moderate frequency
|
|
||||||
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
|
|
||||||
|
|
||||||
// Survey data - changes moderately
|
|
||||||
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
|
|
||||||
|
|
||||||
// Billing data - expensive to compute
|
|
||||||
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
|
|
||||||
|
|
||||||
// Action classes - infrequent changes
|
|
||||||
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
## High-Frequency Endpoint Optimization
|
|
||||||
|
|
||||||
### Performance Patterns for High-Volume Endpoints
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Optimized high-frequency endpoint pattern
|
|
||||||
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simple validation (avoid Zod for high-frequency)
|
|
||||||
if (!params.id || typeof params.id !== 'string') {
|
|
||||||
return responses.badRequestResponse("ID is required", undefined, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single optimized query with caching
|
|
||||||
const data = await getOptimizedData(params.id);
|
|
||||||
|
|
||||||
return responses.successResponse(
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// Simplified error handling for performance
|
|
||||||
if (err instanceof ResourceNotFoundError) {
|
|
||||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
|
||||||
}
|
|
||||||
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
|
|
||||||
return responses.internalServerErrorResponse(err.message, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avoid These Performance Anti-Patterns
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Avoid for high-frequency endpoints
|
|
||||||
const inputValidation = ZodSchema.safeParse(input); // Too slow
|
|
||||||
const startTime = Date.now(); logger.debug(...); // Logging overhead
|
|
||||||
const { data, revalidateEnvironment } = await get(); // Complex return types
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORS Optimization
|
|
||||||
```typescript
|
|
||||||
// ✅ Balanced CORS caching (not too aggressive)
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse(
|
|
||||||
{},
|
|
||||||
true,
|
|
||||||
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Redis Cache Migration from Next.js
|
|
||||||
|
|
||||||
### Avoid Legacy Next.js Patterns
|
|
||||||
```typescript
|
|
||||||
// ❌ Old Next.js unstable_cache pattern (avoid)
|
|
||||||
const getCachedData = unstable_cache(
|
|
||||||
async (id) => fetchData(id),
|
|
||||||
['cache-key'],
|
|
||||||
{ tags: ['environment'], revalidate: 900 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ❌ Don't use revalidateEnvironment flags with Redis
|
|
||||||
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
|
|
||||||
|
|
||||||
// ✅ New Redis pattern with withCache (TTL in milliseconds)
|
|
||||||
export const getCachedData = (id: string) =>
|
|
||||||
withCache(
|
|
||||||
() => fetchData(id),
|
|
||||||
{
|
|
||||||
key: createCacheKey.environment.data(id),
|
|
||||||
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remove Revalidation Logic
|
|
||||||
When migrating from Next.js `unstable_cache`:
|
|
||||||
- Remove `revalidateEnvironment` or similar flags
|
|
||||||
- Remove tag-based invalidation logic
|
|
||||||
- Use TTL-based expiration instead
|
|
||||||
- Handle one-time updates (like `appSetupCompleted`) directly in cache
|
|
||||||
|
|
||||||
## Data Layer Optimization
|
|
||||||
|
|
||||||
### Single Query Pattern
|
|
||||||
```typescript
|
|
||||||
// ✅ Optimize with single database query
|
|
||||||
export const getOptimizedEnvironmentData = async (environmentId: string) => {
|
|
||||||
return prisma.environment.findUniqueOrThrow({
|
|
||||||
where: { id: environmentId },
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: { id: true, recontactDays: true, /* ... */ }
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
select: { id: true, billing: true }
|
|
||||||
},
|
|
||||||
surveys: {
|
|
||||||
where: { status: "inProgress" },
|
|
||||||
select: { id: true, name: true, /* ... */ }
|
|
||||||
},
|
|
||||||
actionClasses: {
|
|
||||||
select: { id: true, name: true, /* ... */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ❌ Avoid multiple separate queries
|
|
||||||
const environment = await getEnvironment(id);
|
|
||||||
const organization = await getOrganization(environment.organizationId);
|
|
||||||
const surveys = await getSurveys(id);
|
|
||||||
const actionClasses = await getActionClasses(id);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Invalidation Best Practices
|
|
||||||
|
|
||||||
**Always use explicit key-based invalidation:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Clear and debuggable
|
|
||||||
await invalidateCache(createCacheKey.environment.state(environmentId));
|
|
||||||
await invalidateCache([
|
|
||||||
createCacheKey.environment.surveys(environmentId),
|
|
||||||
createCacheKey.environment.actionClasses(environmentId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ❌ Avoid complex tag systems
|
|
||||||
await invalidateByTags(["environment", "survey"]); // Don't do this
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Performance Targets
|
|
||||||
|
|
||||||
### High-Frequency Endpoint Goals
|
|
||||||
- **Cache hit ratio**: >85%
|
|
||||||
- **Response time P95**: <200ms
|
|
||||||
- **Database load reduction**: >60%
|
|
||||||
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
|
|
||||||
- **SDK refresh interval**: 1 hour with 30min server cache
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
- Use **existing elastic cache analytics** for metrics
|
|
||||||
- Log cache errors and warnings (not debug info)
|
|
||||||
- Track database query reduction
|
|
||||||
- Monitor response times for cached endpoints
|
|
||||||
- **Avoid performance logging** in high-frequency endpoints
|
|
||||||
|
|
||||||
## Error Handling Pattern
|
|
||||||
|
|
||||||
Always provide fallback to fresh data on cache errors:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const cached = await cache.get(key);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const fresh = await fetchFresh();
|
|
||||||
await cache.set(key, fresh, ttl); // ttl in milliseconds
|
|
||||||
return fresh;
|
|
||||||
} catch (error) {
|
|
||||||
// ✅ Always fallback to fresh data
|
|
||||||
logger.warn("Cache error, fetching fresh", { key, error });
|
|
||||||
return fetchFresh();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Pitfalls to Avoid
|
|
||||||
|
|
||||||
1. **Never use Next.js `unstable_cache()`** - unreliable in production
|
|
||||||
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
|
|
||||||
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
|
|
||||||
4. **Don't add performance logging** to high-frequency endpoints
|
|
||||||
5. **Coordinate TTLs** between client and server caches
|
|
||||||
6. **Don't over-engineer** with complex tag systems
|
|
||||||
7. **Avoid caching rapidly changing data** (real-time metrics)
|
|
||||||
8. **Always validate cache keys** to prevent collisions
|
|
||||||
9. **Don't add redundant caching layers** - analyze computational overhead first
|
|
||||||
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
|
|
||||||
11. **Don't cache property access or simple transformations** - overhead is negligible
|
|
||||||
12. **Analyze the full call chain** before adding caching to avoid double-caching
|
|
||||||
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
|
|
||||||
|
|
||||||
## Monitoring Strategy
|
|
||||||
|
|
||||||
- Use **existing elastic cache analytics** for metrics
|
|
||||||
- Log cache errors and warnings
|
|
||||||
- Track database query reduction
|
|
||||||
- Monitor response times for cached endpoints
|
|
||||||
- **Don't add custom metrics** that duplicate existing monitoring
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### TTL Units
|
|
||||||
- **cache-manager + Keyv**: TTL in **milliseconds**
|
|
||||||
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
|
|
||||||
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
|
|
||||||
- **Client SDK**: TTL in **seconds** (expiresAt calculation)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Database Performance & Prisma Best Practices
|
|
||||||
|
|
||||||
## Critical Performance Rules
|
|
||||||
|
|
||||||
### Response Count Queries
|
|
||||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
|
||||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
|
||||||
- For pagination, separate count queries from data queries
|
|
||||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
|
||||||
|
|
||||||
### Prisma Query Optimization
|
|
||||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
|
||||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
|
||||||
- Use cursor-based pagination for large datasets instead of offset-based
|
|
||||||
- Cache frequently accessed data using React Cache and custom cache tags
|
|
||||||
|
|
||||||
### Date Range Filtering
|
|
||||||
- When filtering by `createdAt`, always use indexed queries
|
|
||||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
|
||||||
- Avoid complex WHERE clauses that can't utilize indexes
|
|
||||||
|
|
||||||
### Count vs Data Separation
|
|
||||||
- Always separate count queries from data fetching queries
|
|
||||||
- Use `Promise.all()` to run count and data queries in parallel
|
|
||||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
|
||||||
```typescript
|
|
||||||
const [responses, totalCount] = await Promise.all([
|
|
||||||
prisma.response.findMany(query),
|
|
||||||
prisma.response.count({ where: whereClause }),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring & Debugging
|
|
||||||
- Monitor AWS RDS Performance Insights for problematic queries
|
|
||||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
|
||||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
description: >
|
|
||||||
globs: schema.prisma
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Formbricks Database Schema Reference
|
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
|
||||||
|
|
||||||
## Database Overview
|
|
||||||
|
|
||||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
|
||||||
|
|
||||||
### Core Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
Organization
|
|
||||||
└── Project
|
|
||||||
└── Environment (production/development)
|
|
||||||
├── Survey
|
|
||||||
├── Contact
|
|
||||||
├── ActionClass
|
|
||||||
└── Integration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schema Reference
|
|
||||||
|
|
||||||
For the complete and up-to-date database schema, please refer to:
|
|
||||||
|
|
||||||
- Main schema: `packages/database/schema.prisma`
|
|
||||||
- JSON type definitions: `packages/database/json-types.ts`
|
|
||||||
|
|
||||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
|
||||||
|
|
||||||
## Data Access Patterns
|
|
||||||
|
|
||||||
### Multi-tenancy
|
|
||||||
|
|
||||||
- All data is scoped by Organization
|
|
||||||
- Environment-level isolation for surveys and contacts
|
|
||||||
- Project-level grouping for related surveys
|
|
||||||
|
|
||||||
### Soft Deletion
|
|
||||||
|
|
||||||
Some models use soft deletion patterns:
|
|
||||||
|
|
||||||
- Check `isActive` fields where present
|
|
||||||
- Use proper filtering in queries
|
|
||||||
|
|
||||||
### Cascading Deletes
|
|
||||||
|
|
||||||
Configured cascade relationships:
|
|
||||||
|
|
||||||
- Organization deletion cascades to all child entities
|
|
||||||
- Survey deletion removes responses, displays, triggers
|
|
||||||
- Contact deletion removes attributes and responses
|
|
||||||
|
|
||||||
## Common Query Patterns
|
|
||||||
|
|
||||||
### Survey with Responses
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Include response count and latest responses
|
|
||||||
const survey = await prisma.survey.findUnique({
|
|
||||||
where: { id: surveyId },
|
|
||||||
include: {
|
|
||||||
responses: {
|
|
||||||
take: 10,
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: { responses: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Scoping
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Always scope by environment
|
|
||||||
const surveys = await prisma.survey.findMany({
|
|
||||||
where: {
|
|
||||||
environmentId: environmentId,
|
|
||||||
// Additional filters...
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contact with Attributes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const contact = await prisma.contact.findUnique({
|
|
||||||
where: { id: contactId },
|
|
||||||
include: {
|
|
||||||
attributes: {
|
|
||||||
include: {
|
|
||||||
attributeKey: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
title: "FEATURE NAME"
|
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
|
||||||
icon: "link"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
|
||||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
|
||||||
- The page should never start with H1 headline, because it's already part of the template.
|
|
||||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
|
||||||
- If a feature is part of the Enterprise Edition, use this note:
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
|
||||||
</Note>
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Formbricks Architecture & Patterns
|
|
||||||
|
|
||||||
## Monorepo Structure
|
|
||||||
|
|
||||||
### Apps Directory
|
|
||||||
- `apps/web/` - Main Next.js web application
|
|
||||||
- `packages/` - Shared packages and utilities
|
|
||||||
|
|
||||||
### Key Directories in Web App
|
|
||||||
```
|
|
||||||
apps/web/
|
|
||||||
├── app/ # Next.js 13+ app directory
|
|
||||||
│ ├── (app)/ # Main application routes
|
|
||||||
│ ├── (auth)/ # Authentication routes
|
|
||||||
│ ├── api/ # API routes
|
|
||||||
├── components/ # Shared components
|
|
||||||
├── lib/ # Utility functions and services
|
|
||||||
└── modules/ # Feature-specific modules
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routing Patterns
|
|
||||||
|
|
||||||
### App Router Structure
|
|
||||||
The application uses Next.js 13+ app router with route groups:
|
|
||||||
|
|
||||||
```
|
|
||||||
(app)/environments/[environmentId]/
|
|
||||||
├── surveys/[surveyId]/
|
|
||||||
│ ├── (analysis)/ # Analysis views
|
|
||||||
│ │ ├── responses/ # Response management
|
|
||||||
│ │ ├── summary/ # Survey summary
|
|
||||||
│ │ └── hooks/ # Analysis-specific hooks
|
|
||||||
│ ├── edit/ # Survey editing
|
|
||||||
│ └── settings/ # Survey settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic Routes
|
|
||||||
- `[environmentId]` - Environment-specific routes
|
|
||||||
- `[surveyId]` - Survey-specific routes
|
|
||||||
|
|
||||||
## Service Layer Pattern
|
|
||||||
|
|
||||||
### Service Organization
|
|
||||||
Services are organized by domain in `apps/web/lib/`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Example: Response service
|
|
||||||
// apps/web/lib/response/service.ts
|
|
||||||
export const getResponseCountAction = async ({
|
|
||||||
surveyId,
|
|
||||||
filterCriteria,
|
|
||||||
}: {
|
|
||||||
surveyId: string;
|
|
||||||
filterCriteria: any;
|
|
||||||
}) => {
|
|
||||||
// Service implementation
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Action Pattern
|
|
||||||
Server actions follow a consistent pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Action wrapper for service calls
|
|
||||||
export const getResponseCountAction = async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await responseService.getCount(params);
|
|
||||||
return { data: result };
|
|
||||||
} catch (error) {
|
|
||||||
return { error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Context Patterns
|
|
||||||
|
|
||||||
### Provider Structure
|
|
||||||
Context providers follow a consistent pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Provider component
|
|
||||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
selectedFilter,
|
|
||||||
setSelectedFilter,
|
|
||||||
// ... other state and methods
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponseFilterContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</ResponseFilterContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for consuming context
|
|
||||||
export const useResponseFilter = () => {
|
|
||||||
const context = useContext(ResponseFilterContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Composition
|
|
||||||
Multiple contexts are often composed together:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Layout component with multiple providers
|
|
||||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<ResponseCountProvider>
|
|
||||||
{children}
|
|
||||||
</ResponseCountProvider>
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
### Page Components
|
|
||||||
Page components are located in the app directory and follow this pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
|
||||||
export default function ResponsesPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ResponsesTable />
|
|
||||||
<ResponsesPagination />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
- **Pages** - Route components in app directory
|
|
||||||
- **Components** - Reusable UI components
|
|
||||||
- **Modules** - Feature-specific components and logic
|
|
||||||
|
|
||||||
### Shared Components
|
|
||||||
Common components are in `apps/web/components/`:
|
|
||||||
- UI components (buttons, inputs, modals)
|
|
||||||
- Layout components (headers, sidebars)
|
|
||||||
- Data display components (tables, charts)
|
|
||||||
|
|
||||||
## Hook Patterns
|
|
||||||
|
|
||||||
### Custom Hook Structure
|
|
||||||
Custom hooks follow consistent patterns:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const useResponseCount = ({
|
|
||||||
survey,
|
|
||||||
initialCount
|
|
||||||
}: {
|
|
||||||
survey: TSurvey;
|
|
||||||
initialCount?: number;
|
|
||||||
}) => {
|
|
||||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Hook logic...
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseCount,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Dependencies
|
|
||||||
- Use context hooks for shared state
|
|
||||||
- Implement proper cleanup with AbortController
|
|
||||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
|
||||||
|
|
||||||
## Data Fetching Patterns
|
|
||||||
|
|
||||||
### Server Actions
|
|
||||||
The app uses Next.js server actions for data fetching:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Server action
|
|
||||||
export async function getResponsesAction(params: GetResponsesParams) {
|
|
||||||
const responses = await getResponses(params);
|
|
||||||
return { data: responses };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client usage
|
|
||||||
const { data } = await getResponsesAction(params);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
Consistent error handling across the application:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const result = await apiCall();
|
|
||||||
return { data: result };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Operation failed:", error);
|
|
||||||
return { error: error.message };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Safety
|
|
||||||
|
|
||||||
### Type Organization
|
|
||||||
Types are organized in packages:
|
|
||||||
- `@formbricks/types` - Shared type definitions
|
|
||||||
- Local types in component/hook files
|
|
||||||
|
|
||||||
### Common Types
|
|
||||||
```typescript
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
|
|
||||||
### Local State
|
|
||||||
- Use `useState` for component-specific state
|
|
||||||
- Use `useReducer` for complex state logic
|
|
||||||
- Use refs for mutable values that don't trigger re-renders
|
|
||||||
|
|
||||||
### Global State
|
|
||||||
- React Context for feature-specific shared state
|
|
||||||
- URL state for filters and pagination
|
|
||||||
- Server state through server actions
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Code Splitting
|
|
||||||
- Dynamic imports for heavy components
|
|
||||||
- Route-based code splitting with app router
|
|
||||||
- Lazy loading for non-critical features
|
|
||||||
|
|
||||||
### Caching Strategy
|
|
||||||
- Server-side caching for database queries
|
|
||||||
- Client-side caching with React Query (where applicable)
|
|
||||||
- Static generation for public pages
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
```
|
|
||||||
component/
|
|
||||||
├── Component.tsx
|
|
||||||
├── Component.test.tsx
|
|
||||||
└── hooks/
|
|
||||||
├── useHook.ts
|
|
||||||
└── useHook.test.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Patterns
|
|
||||||
- Unit tests for utilities and services
|
|
||||||
- Integration tests for components with context
|
|
||||||
- Hook tests with proper mocking
|
|
||||||
|
|
||||||
## Build & Deployment
|
|
||||||
|
|
||||||
### Build Process
|
|
||||||
- TypeScript compilation
|
|
||||||
- Next.js build optimization
|
|
||||||
- Asset optimization and bundling
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
- Environment-specific configurations
|
|
||||||
- Feature flags for gradual rollouts
|
|
||||||
- Database connection management
|
|
||||||
|
|
||||||
## Security Patterns
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- Session-based authentication
|
|
||||||
- Environment-based access control
|
|
||||||
- API route protection
|
|
||||||
|
|
||||||
### Data Validation
|
|
||||||
- Input validation on both client and server
|
|
||||||
- Type-safe API contracts
|
|
||||||
- Sanitization of user inputs
|
|
||||||
|
|
||||||
## Monitoring & Observability
|
|
||||||
|
|
||||||
### Error Tracking
|
|
||||||
- Client-side error boundaries
|
|
||||||
- Server-side error logging
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
### Analytics
|
|
||||||
- User interaction tracking
|
|
||||||
- Performance metrics
|
|
||||||
- Database query monitoring
|
|
||||||
|
|
||||||
## Best Practices Summary
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- ✅ Follow the established directory structure
|
|
||||||
- ✅ Use consistent naming conventions
|
|
||||||
- ✅ Separate concerns (UI, logic, data)
|
|
||||||
- ✅ Keep components focused and small
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Implement proper loading states
|
|
||||||
- ✅ Use AbortController for async operations
|
|
||||||
- ✅ Optimize database queries
|
|
||||||
- ✅ Implement proper caching strategies
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- ✅ Use TypeScript throughout
|
|
||||||
- ✅ Define proper interfaces for props
|
|
||||||
- ✅ Use type guards for runtime validation
|
|
||||||
- ✅ Leverage shared type packages
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ Write tests for critical functionality
|
|
||||||
- ✅ Mock external dependencies properly
|
|
||||||
- ✅ Test error scenarios and edge cases
|
|
||||||
- ✅ Maintain good test coverage
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
|
||||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Actions Security Best Practices
|
|
||||||
|
|
||||||
## Required Security Measures
|
|
||||||
|
|
||||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
|
||||||
|
|
||||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Only add additional permissions if absolutely necessary:
|
|
||||||
# pull-requests: write # for commenting on PRs
|
|
||||||
# issues: write # for creating/updating issues
|
|
||||||
# checks: write # for publishing check results
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Harden-Runner as First Step
|
|
||||||
|
|
||||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit # or 'block' for stricter security
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Pin Actions to Full Commit SHA
|
|
||||||
|
|
||||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - uses mutable tag
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to immutable commit SHA
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Secure Variable Handling
|
|
||||||
|
|
||||||
Prevent command injection by properly quoting variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - potential command injection
|
|
||||||
run: echo "Processing ${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
# ✅ GOOD - properly quoted
|
|
||||||
env:
|
|
||||||
USER_INPUT: ${{ inputs.user_input }}
|
|
||||||
run: echo "Processing ${USER_INPUT}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
|
||||||
|
|
||||||
### 5. Environment Variables for Secrets
|
|
||||||
|
|
||||||
Store sensitive data in environment variables, not inline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD
|
|
||||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
|
||||||
|
|
||||||
# ✅ GOOD
|
|
||||||
env:
|
|
||||||
API_TOKEN: ${{ secrets.TOKEN }}
|
|
||||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Structure Best Practices
|
|
||||||
|
|
||||||
### Required Workflow Elements
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: "Descriptive Workflow Name"
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Define specific triggers
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Always set explicit permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
job-name:
|
|
||||||
name: "Descriptive Job Name"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
|
||||||
|
|
||||||
# Set job-level permissions if different from workflow level
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Always start with Harden-Runner on ubuntu-latest
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Pin all actions to commit SHA
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Input Validation for Actions
|
|
||||||
|
|
||||||
For composite actions, always validate inputs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
inputs:
|
|
||||||
user_input:
|
|
||||||
description: "User provided input"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Validate input
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Harden shell and validate input format/content before use
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
USER_INPUT="${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Invalid input format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Security in Actions
|
|
||||||
|
|
||||||
### Pin Docker Images to Digests
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - mutable tag
|
|
||||||
container: node:18
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to digest
|
|
||||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Secure File Operations
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process files securely
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FILE_PATH: ${{ inputs.file_path }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
|
||||||
|
|
||||||
# Use absolute paths and validate
|
|
||||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
|
||||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
|
||||||
echo "❌ Path outside workspace"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Artifact Handling
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload artifacts securely
|
|
||||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
dist/
|
|
||||||
!dist/**/*.log # Exclude sensitive files
|
|
||||||
retention-days: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### GHCR authentication for pulls/scans
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Minimal permissions required for GHCR pulls/scans
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
|
||||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
|
||||||
- [ ] All third-party actions pinned to commit SHA
|
|
||||||
- [ ] Input validation implemented for custom actions
|
|
||||||
- [ ] Variables properly quoted in shell scripts
|
|
||||||
- [ ] Secrets stored in environment variables
|
|
||||||
- [ ] Docker images pinned to digests (if used)
|
|
||||||
- [ ] Error handling with `set -euo pipefail`
|
|
||||||
- [ ] File paths validated and sanitized
|
|
||||||
- [ ] No sensitive data in logs or outputs
|
|
||||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
|
||||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
|
||||||
|
|
||||||
## Recommended Additional Workflows
|
|
||||||
|
|
||||||
Consider adding these security-focused workflows to your repository:
|
|
||||||
|
|
||||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
|
||||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
|
||||||
3. **Dependabot Configuration** - Automated dependency updates
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
|
||||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
|
||||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
---
|
|
||||||
title: i18n Management with Lingo.dev
|
|
||||||
description: Guidelines for managing internationalization (i18n) with Lingo.dev, including translation workflow, key validation, and best practices
|
|
||||||
---
|
|
||||||
|
|
||||||
# i18n Management with Lingo.dev
|
|
||||||
|
|
||||||
This rule defines the workflow and best practices for managing internationalization (i18n) in the Formbricks project using Lingo.dev.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Formbricks uses [Lingo.dev](https://lingo.dev) for managing translations across multiple languages. The translation workflow includes:
|
|
||||||
|
|
||||||
1. **Translation Keys**: Defined in code using the `t()` function from `react-i18next`
|
|
||||||
2. **Translation Files**: JSON files stored in `apps/web/locales/` for each supported language
|
|
||||||
3. **Validation**: Automated scanning to detect missing and unused translation keys
|
|
||||||
4. **CI/CD**: Pre-commit hooks and GitHub Actions to enforce translation quality
|
|
||||||
|
|
||||||
## Translation Workflow
|
|
||||||
|
|
||||||
### 1. Using Translations in Code
|
|
||||||
|
|
||||||
When adding translatable text in the web app, use the `t()` function or `<Trans>` component:
|
|
||||||
|
|
||||||
**Using the `t()` function:**
|
|
||||||
```tsx
|
|
||||||
import { useTranslate } from "@/lib/i18n/translate";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>{t("common.welcome")}</h1>
|
|
||||||
<p>{t("pages.dashboard.description")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Using the `<Trans>` component (for text with HTML elements):**
|
|
||||||
```tsx
|
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<Trans
|
|
||||||
i18nKey="auth.terms_agreement"
|
|
||||||
components={{
|
|
||||||
link: <a href="/terms" />,
|
|
||||||
b: <b />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Naming Conventions:**
|
|
||||||
- Use dot notation for nested keys: `section.subsection.key`
|
|
||||||
- Use descriptive names: `auth.login.success_message` not `auth.msg1`
|
|
||||||
- Group related keys together: `auth.*`, `errors.*`, `common.*`
|
|
||||||
- Use lowercase with underscores: `user_profile_settings` not `UserProfileSettings`
|
|
||||||
|
|
||||||
### 2. Translation File Structure
|
|
||||||
|
|
||||||
Translation files are located in `apps/web/locales/` and use the following naming convention:
|
|
||||||
- `en-US.json` (English - United States, default)
|
|
||||||
- `de-DE.json` (German)
|
|
||||||
- `fr-FR.json` (French)
|
|
||||||
- `pt-BR.json` (Portuguese - Brazil)
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
**File Structure:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"common": {
|
|
||||||
"welcome": "Welcome",
|
|
||||||
"save": "Save",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"login": {
|
|
||||||
"title": "Login",
|
|
||||||
"email_placeholder": "Enter your email",
|
|
||||||
"password_placeholder": "Enter your password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Adding New Translation Keys
|
|
||||||
|
|
||||||
When adding new translation keys:
|
|
||||||
|
|
||||||
1. **Add the key in your code** using `t("your.new.key")`
|
|
||||||
2. **Add translation for that key in en-US.json file**
|
|
||||||
3. **Run the translation workflow:**
|
|
||||||
```bash
|
|
||||||
pnpm i18n
|
|
||||||
```
|
|
||||||
This will:
|
|
||||||
- Generate translations for all languages using Lingo.dev
|
|
||||||
- Validate that all keys are present and used
|
|
||||||
|
|
||||||
4. **Review and commit** the generated translation files
|
|
||||||
|
|
||||||
### 4. Available Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate translations using Lingo.dev
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# Scan and validate translation keys
|
|
||||||
pnpm scan-translations
|
|
||||||
|
|
||||||
# Full workflow: generate + validate
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Validate only (without generation)
|
|
||||||
pnpm i18n:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Translation Key Validation
|
|
||||||
|
|
||||||
### Automated Validation
|
|
||||||
|
|
||||||
The project includes automated validation that runs:
|
|
||||||
- **Pre-commit hook**: Validates translations before allowing commits (when `LINGODOTDEV_API_KEY` is set)
|
|
||||||
- **GitHub Actions**: Validates translations on every PR and push to main
|
|
||||||
|
|
||||||
### Validation Rules
|
|
||||||
|
|
||||||
The validation script (`scan-translations.ts`) checks for:
|
|
||||||
|
|
||||||
1. **Missing Keys**: Translation keys used in code but not present in translation files
|
|
||||||
2. **Unused Keys**: Translation keys present in translation files but not used in code
|
|
||||||
3. **Incomplete Translations**: Keys that exist in the default language (`en-US`) but are missing in target languages
|
|
||||||
|
|
||||||
**What gets scanned:**
|
|
||||||
- All `.ts` and `.tsx` files in `apps/web/`
|
|
||||||
- Both `t()` function calls and `<Trans i18nKey="">` components
|
|
||||||
- All locale files (`de-DE.json`, `fr-FR.json`, `ja-JP.json`, etc.)
|
|
||||||
|
|
||||||
**What gets excluded:**
|
|
||||||
- Test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
|
||||||
- Build directories (`node_modules`, `dist`, `build`, `.next`, `coverage`)
|
|
||||||
- Locale files themselves (from code scanning)
|
|
||||||
|
|
||||||
**Note:** Test files are excluded because they often use mock or example translation keys for testing purposes that don't need to exist in production translation files.
|
|
||||||
|
|
||||||
### Fixing Validation Errors
|
|
||||||
|
|
||||||
#### Missing Keys
|
|
||||||
|
|
||||||
If you encounter missing key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ MISSING KEYS (2):
|
|
||||||
|
|
||||||
These keys are used in code but not found in translation files:
|
|
||||||
|
|
||||||
• auth.signup.email_required
|
|
||||||
• settings.profile.update_success
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. Ensure that translations for those keys are present in en-US.json .
|
|
||||||
2. Run `pnpm generate-translations` to have Lingo.dev generate the missing translations
|
|
||||||
3. OR manually add the keys to `apps/web/locales/en-US.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"signup": {
|
|
||||||
"email_required": "Email is required"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"profile": {
|
|
||||||
"update_success": "Profile updated successfully"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Unused Keys
|
|
||||||
|
|
||||||
If you encounter unused key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ UNUSED KEYS (1):
|
|
||||||
|
|
||||||
These keys exist in translation files but are not used in code:
|
|
||||||
|
|
||||||
• old.deprecated.key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. If the key is truly unused, remove it from all translation files
|
|
||||||
2. If the key should be used, add it to your code using `t("old.deprecated.key")`
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Incomplete Translations
|
|
||||||
|
|
||||||
If you encounter incomplete translation errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ INCOMPLETE TRANSLATIONS:
|
|
||||||
|
|
||||||
Some keys from en-US are missing in target languages:
|
|
||||||
|
|
||||||
📝 de-DE (5 missing keys):
|
|
||||||
• auth.new_feature.title
|
|
||||||
• auth.new_feature.description
|
|
||||||
• settings.advanced.option
|
|
||||||
... and 2 more
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. **Recommended:** Run `pnpm generate-translations` to have Lingo.dev automatically translate the missing keys
|
|
||||||
2. **Manual:** Add the missing keys to the target language files:
|
|
||||||
```bash
|
|
||||||
# Copy the structure from en-US.json and translate the values
|
|
||||||
# For example, in de-DE.json:
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"new_feature": {
|
|
||||||
"title": "Neues Feature",
|
|
||||||
"description": "Beschreibung des neuen Features"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify all translations are complete
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
## Pre-commit Hook Behavior
|
|
||||||
|
|
||||||
The pre-commit hook will:
|
|
||||||
|
|
||||||
1. Run `lint-staged` for code formatting
|
|
||||||
2. If `LINGODOTDEV_API_KEY` is set:
|
|
||||||
- Generate translations using Lingo.dev
|
|
||||||
- Validate translation keys
|
|
||||||
- Auto-add updated locale files to the commit
|
|
||||||
- **Block the commit** if validation fails
|
|
||||||
3. If `LINGODOTDEV_API_KEY` is not set:
|
|
||||||
- Skip translation validation (for community contributors)
|
|
||||||
- Show a warning message
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### LINGODOTDEV_API_KEY
|
|
||||||
|
|
||||||
This is the API key for Lingo.dev integration.
|
|
||||||
|
|
||||||
**For Core Team:**
|
|
||||||
- Add to your local `.env` file
|
|
||||||
- Required for running translation generation
|
|
||||||
|
|
||||||
**For Community Contributors:**
|
|
||||||
- Not required for local development
|
|
||||||
- Translation validation will be skipped
|
|
||||||
- The CI will still validate translations
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Keep Keys Organized
|
|
||||||
|
|
||||||
Group related keys together:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"login": { ... },
|
|
||||||
"signup": { ... },
|
|
||||||
"forgot_password": { ... }
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"header": { ... },
|
|
||||||
"sidebar": { ... }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Avoid Hardcoded Strings
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
<button>Click here</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
<button>{t("common.click_here")}</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use Interpolation for Dynamic Content
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
{t("welcome")} {userName}!
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
{t("auth.welcome_message", { userName })}
|
|
||||||
```
|
|
||||||
|
|
||||||
With translation:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"welcome_message": "Welcome, {userName}!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Avoid Dynamic Key Construction
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
const key = `errors.${errorCode}`;
|
|
||||||
t(key);
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
switch (errorCode) {
|
|
||||||
case "401":
|
|
||||||
return t("errors.unauthorized");
|
|
||||||
case "404":
|
|
||||||
return t("errors.not_found");
|
|
||||||
default:
|
|
||||||
return t("errors.unknown");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Test Translation Keys
|
|
||||||
|
|
||||||
When adding new features:
|
|
||||||
1. Add translation keys
|
|
||||||
2. Test in multiple languages using the language switcher
|
|
||||||
3. Ensure text doesn't overflow in longer translations (German, French)
|
|
||||||
4. Run `pnpm scan-translations` before committing
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Pre-commit hook fails with validation errors
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Run the full i18n workflow
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Fix any missing or unused keys
|
|
||||||
# Then commit again
|
|
||||||
git add .
|
|
||||||
git commit -m "your message"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Translation validation passes locally but fails in CI
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Ensure all translation files are committed
|
|
||||||
- Check that `scan-translations.ts` hasn't been modified
|
|
||||||
- Verify that locale files are properly formatted JSON
|
|
||||||
|
|
||||||
### Issue: Cannot commit because of missing translations
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# If you have LINGODOTDEV_API_KEY:
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# If you don't have the API key (community contributor):
|
|
||||||
# Manually add the missing keys to en-US.json
|
|
||||||
# Then run validation:
|
|
||||||
pnpm scan-translations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Getting "unused keys" for keys that are used
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- The script scans `.ts` and `.tsx` files only
|
|
||||||
- If keys are used in other file types, they may be flagged
|
|
||||||
- Verify the key is actually used with `grep -r "your.key" apps/web/`
|
|
||||||
- If it's a false positive, consider updating the scanning patterns in `scan-translations.ts`
|
|
||||||
|
|
||||||
## AI Assistant Guidelines
|
|
||||||
|
|
||||||
When assisting with i18n-related tasks, always:
|
|
||||||
|
|
||||||
1. **Use the `t()` function** for all user-facing text
|
|
||||||
2. **Follow key naming conventions** (lowercase, dots for nesting)
|
|
||||||
3. **Run validation** after making changes: `pnpm scan-translations`
|
|
||||||
4. **Fix missing keys** by adding them to `en-US.json`
|
|
||||||
5. **Remove unused keys** from all translation files
|
|
||||||
6. **Test the pre-commit hook** if making changes to translation workflow
|
|
||||||
7. **Update this rule file** if translation workflow changes
|
|
||||||
|
|
||||||
### Fixing Missing Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters missing translation key errors:
|
|
||||||
|
|
||||||
1. Identify the missing keys from the error output
|
|
||||||
2. Determine the appropriate section and naming for each key
|
|
||||||
3. Add the keys to `apps/web/locales/en-US.json` with meaningful English text
|
|
||||||
4. Ensure proper JSON structure and nesting
|
|
||||||
5. Run `pnpm scan-translations` to verify
|
|
||||||
6. Inform the user that other language files will be updated via Lingo.dev
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
|
||||||
// Error: Missing key "settings.api.rate_limit_exceeded"
|
|
||||||
|
|
||||||
// Add to en-US.json:
|
|
||||||
{
|
|
||||||
"settings": {
|
|
||||||
"api": {
|
|
||||||
"rate_limit_exceeded": "API rate limit exceeded. Please try again later."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Removing Unused Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters unused translation key errors:
|
|
||||||
|
|
||||||
1. Verify the keys are truly unused by searching the codebase
|
|
||||||
2. Remove the keys from `apps/web/locales/en-US.json`
|
|
||||||
3. Note that removal from other language files can be handled via Lingo.dev
|
|
||||||
4. Run `pnpm scan-translations` to verify
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
This project previously used Tolgee for translations. As of this migration:
|
|
||||||
|
|
||||||
- **Old scripts**: `tolgee-pull` is deprecated (kept for reference)
|
|
||||||
- **New scripts**: Use `pnpm i18n` or `pnpm generate-translations`
|
|
||||||
- **Old workflows**: `tolgee.yml` and `tolgee-missing-key-check.yml` removed
|
|
||||||
- **New workflow**: `translation-check.yml` handles all validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** October 14, 2025
|
|
||||||
**Related Files:**
|
|
||||||
- `scan-translations.ts` - Translation validation script
|
|
||||||
- `.husky/pre-commit` - Pre-commit hook with i18n validation
|
|
||||||
- `.github/workflows/translation-check.yml` - CI workflow for translation validation
|
|
||||||
- `apps/web/locales/*.json` - Translation files
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# React Context & Provider Patterns
|
|
||||||
|
|
||||||
## Context Provider Best Practices
|
|
||||||
|
|
||||||
### Provider Implementation
|
|
||||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
|
||||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
|
||||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
|
||||||
|
|
||||||
### Cleanup Pattern for Refs
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPendingRequests = pendingRequests.current;
|
|
||||||
const currentAbortController = abortController.current;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (currentAbortController) {
|
|
||||||
currentAbortController.abort();
|
|
||||||
}
|
|
||||||
currentPendingRequests.clear();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Context Providers
|
|
||||||
- Always wrap components using context in the provider during tests
|
|
||||||
- Use `initialCount` prop for predictable test scenarios
|
|
||||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
|
||||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
render(
|
|
||||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
|
||||||
<ComponentUnderTest />
|
|
||||||
</ResponseCountProvider>
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Mocks for Context Testing
|
|
||||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
|
||||||
- Mock response filter context and actions
|
|
||||||
- Mock API actions that the provider depends on
|
|
||||||
|
|
||||||
### Context Hook Usage
|
|
||||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
|
||||||
- Provide meaningful error messages when context is used outside provider
|
|
||||||
- Use context for shared state that multiple components need to access
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
---
|
|
||||||
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.**
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
---
|
|
||||||
description: Migrate deprecated UI components to a unified component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Component Migration Automation Rule
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### Step 1: Discovery and Planning
|
|
||||||
1. **Identify migration parameters:**
|
|
||||||
- Ask user for deprecated component name (e.g., "Modal")
|
|
||||||
- Ask user for new component name(s) (e.g., "Dialog")
|
|
||||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
|
||||||
- Ask for specific import paths if needed
|
|
||||||
|
|
||||||
2. **Scan codebase** for deprecated components:
|
|
||||||
- Search for `import.*[DeprecatedComponent]` patterns
|
|
||||||
- Exclude specified components that should not be migrated
|
|
||||||
- List all found components with file paths
|
|
||||||
- Present numbered list to user for confirmation
|
|
||||||
|
|
||||||
### Step 2: Component-by-Component Migration
|
|
||||||
For each component, follow this exact sequence:
|
|
||||||
|
|
||||||
#### 2.1 Component Migration
|
|
||||||
- **Import changes:**
|
|
||||||
- Ask user to provide the new import structure
|
|
||||||
- Example transformation pattern:
|
|
||||||
```typescript
|
|
||||||
// FROM:
|
|
||||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
|
||||||
|
|
||||||
// TO:
|
|
||||||
import {
|
|
||||||
[NewComponent],
|
|
||||||
[NewComponentPart1],
|
|
||||||
[NewComponentPart2],
|
|
||||||
// ... other parts
|
|
||||||
} from "@/components/ui/[NewComponent]"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Props transformation:**
|
|
||||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
|
||||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
|
||||||
- Apply transformations based on user specifications
|
|
||||||
|
|
||||||
- **Structure transformation:**
|
|
||||||
- Ask user for the new component structure pattern
|
|
||||||
- Apply the transformation maintaining all functionality
|
|
||||||
- Preserve all existing logic, state management, and event handlers
|
|
||||||
|
|
||||||
#### 2.2 Wait for User Approval
|
|
||||||
- Present the migration changes
|
|
||||||
- Wait for explicit user approval before proceeding
|
|
||||||
- If rejected, ask for specific feedback and iterate
|
|
||||||
#### 2.3 Re-read and Apply Additional Changes
|
|
||||||
- Re-read the component file to capture any user modifications
|
|
||||||
- Apply any additional improvements the user made
|
|
||||||
- Ensure all changes are incorporated
|
|
||||||
|
|
||||||
#### 2.4 Test File Updates
|
|
||||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
|
||||||
- **Update test mocks:**
|
|
||||||
- Ask user for new component mock structure
|
|
||||||
- Replace old component mocks with new ones
|
|
||||||
- Example pattern:
|
|
||||||
```typescript
|
|
||||||
// Add to test setup:
|
|
||||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
|
||||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
|
||||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
|
||||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
|
||||||
// ... other parts
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
- **Update test expectations:**
|
|
||||||
- Change test IDs from old component to new component
|
|
||||||
- Update any component-specific assertions
|
|
||||||
- Ensure all new component parts used in the component are mocked
|
|
||||||
|
|
||||||
#### 2.5 Run Tests and Optimize
|
|
||||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
|
||||||
- Fix any failing tests
|
|
||||||
- Optimize code quality (imports, formatting, etc.)
|
|
||||||
- Re-run tests until all pass
|
|
||||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
|
||||||
|
|
||||||
#### 2.6 Wait for Final Approval
|
|
||||||
- Present test results and any optimizations made
|
|
||||||
- Wait for user approval of the complete migration
|
|
||||||
- If rejected, iterate based on feedback
|
|
||||||
|
|
||||||
#### 2.7 Git Commit
|
|
||||||
- Run: `git add .`
|
|
||||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
|
||||||
- Confirm commit was successful
|
|
||||||
|
|
||||||
### Step 3: Final Report Generation
|
|
||||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
|
||||||
|
|
||||||
#### PR Title
|
|
||||||
```
|
|
||||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PR Description Template
|
|
||||||
```markdown
|
|
||||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
|
||||||
|
|
||||||
### Components Migrated
|
|
||||||
[List each component with file path]
|
|
||||||
|
|
||||||
### Technical Changes
|
|
||||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
|
||||||
- **Props:** [List prop transformations]
|
|
||||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
|
||||||
- **Styling:** [Describe styling changes]
|
|
||||||
- **Tests:** Updated all test mocks and expectations
|
|
||||||
|
|
||||||
### Migration Pattern
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
<[DeprecatedComponent] [oldProps]>
|
|
||||||
[oldStructure]
|
|
||||||
</[DeprecatedComponent]>
|
|
||||||
|
|
||||||
// After
|
|
||||||
<[NewComponent] [newProps]>
|
|
||||||
[newStructure]
|
|
||||||
</[NewComponent]>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ All existing tests updated and passing
|
|
||||||
- ✅ Component functionality preserved
|
|
||||||
- ✅ UI/UX behavior maintained
|
|
||||||
|
|
||||||
### How to Test This PR
|
|
||||||
1. **Functional Testing:**
|
|
||||||
- Navigate to each migrated component's usage
|
|
||||||
- Verify [component] opens and closes correctly
|
|
||||||
- Test all interactive elements within [components]
|
|
||||||
- Confirm styling and layout are preserved
|
|
||||||
|
|
||||||
2. **Automated Testing:**
|
|
||||||
```bash
|
|
||||||
Node package manager test
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Visual Testing:**
|
|
||||||
- Check that all [components] maintain proper styling
|
|
||||||
- Verify responsive behavior
|
|
||||||
- Test keyboard navigation and accessibility
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
|
||||||
- All form validation and complex state management preserved
|
|
||||||
- Enhanced code quality with better imports and formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
## Special Considerations
|
|
||||||
|
|
||||||
### Excluded Components
|
|
||||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
|
||||||
- They may already use the new component internally or have other reasons
|
|
||||||
- Inform user these are skipped and why
|
|
||||||
|
|
||||||
### Complex Components
|
|
||||||
- Preserve all existing functionality (forms, validation, state management)
|
|
||||||
- Maintain prop interfaces
|
|
||||||
- Keep all event handlers and callbacks
|
|
||||||
- Preserve accessibility features
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- Ensure all new component parts are mocked when used
|
|
||||||
- Mock all new component parts that appear in the component
|
|
||||||
- Update test IDs from old component to new component
|
|
||||||
- Maintain all existing test scenarios
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
|
||||||
- If component is too complex, ask user for specific guidance
|
|
||||||
- If unsure about functionality preservation, ask for clarification
|
|
||||||
|
|
||||||
### Migration Patterns
|
|
||||||
- Always ask user for specific migration patterns before starting
|
|
||||||
- Confirm import structures, prop mappings, and component hierarchies
|
|
||||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
- All deprecated components successfully migrated to new components
|
|
||||||
- All tests passing
|
|
||||||
- No functionality lost
|
|
||||||
- Code quality maintained or improved
|
|
||||||
- User approval on each component
|
|
||||||
- Successful git commits for each migration
|
|
||||||
- Comprehensive PR report generated
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
- "migrate Modal to Dialog"
|
|
||||||
- "migrate Button to NewButton"
|
|
||||||
- "migrate Card to ModernCard"
|
|
||||||
- "component migration" (will prompt for details)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a story in Storybook for a given component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Formbricks Storybook Stories
|
|
||||||
|
|
||||||
## When generating Storybook stories for Formbricks components:
|
|
||||||
|
|
||||||
### 1. **File Structure**
|
|
||||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
|
||||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
|
||||||
- Import component from `"./index"`
|
|
||||||
|
|
||||||
### 2. **Story Structure Template**
|
|
||||||
```tsx
|
|
||||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { ComponentName } from "./index";
|
|
||||||
|
|
||||||
// For complex components with configurable options
|
|
||||||
// consider this as an example the options need to reflect the props types
|
|
||||||
interface StoryOptions {
|
|
||||||
showIcon: boolean;
|
|
||||||
numberOfElements: number;
|
|
||||||
customLabels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
|
||||||
|
|
||||||
const meta: Meta<StoryProps> = {
|
|
||||||
title: "UI/ComponentName",
|
|
||||||
component: ComponentName,
|
|
||||||
tags: ["autodocs"],
|
|
||||||
parameters: {
|
|
||||||
layout: "centered",
|
|
||||||
controls: { sort: "alpha", exclude: [] },
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: "The **ComponentName** component provides [description].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **ArgTypes Organization**
|
|
||||||
Organize ALL argTypes into exactly three categories:
|
|
||||||
- **Behavior**: disabled, variant, onChange, etc.
|
|
||||||
- **Appearance**: size, color, layout, styling, etc.
|
|
||||||
- **Content**: text, icons, numberOfElements, etc.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
```tsx
|
|
||||||
argTypes: {
|
|
||||||
propName: {
|
|
||||||
control: "select" | "boolean" | "text" | "number",
|
|
||||||
options: ["option1", "option2"], // for select
|
|
||||||
description: "Clear description",
|
|
||||||
table: {
|
|
||||||
category: "Behavior" | "Appearance" | "Content",
|
|
||||||
type: { summary: "string" },
|
|
||||||
defaultValue: { summary: "default" },
|
|
||||||
},
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Required Stories**
|
|
||||||
Every component must include:
|
|
||||||
- `Default`: Most common use case
|
|
||||||
- `Disabled`: If component supports disabled state
|
|
||||||
- `WithIcon`: If component supports icons
|
|
||||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
|
||||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
|
||||||
|
|
||||||
### 5. **Story Format**
|
|
||||||
```tsx
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
// Props with realistic values
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EdgeCase: Story = {
|
|
||||||
args: { /* ... */ },
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
story: "Use this when [specific scenario].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Dynamic Content Pattern**
|
|
||||||
For components with dynamic content, create render function:
|
|
||||||
```tsx
|
|
||||||
const renderComponent = (args: StoryProps) => {
|
|
||||||
const { numberOfElements, showIcon, customLabels } = args;
|
|
||||||
|
|
||||||
// Generate dynamic content
|
|
||||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
|
||||||
id: `element-${i}`,
|
|
||||||
label: customLabels[i] || `Element ${i + 1}`,
|
|
||||||
icon: showIcon ? <IconComponent /> : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <ComponentName {...args} elements={elements} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dynamic: Story = {
|
|
||||||
render: renderComponent,
|
|
||||||
args: {
|
|
||||||
numberOfElements: 3,
|
|
||||||
showIcon: true,
|
|
||||||
customLabels: ["First", "Second", "Third"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. **State Management**
|
|
||||||
For interactive components:
|
|
||||||
```tsx
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ComponentWithState = (args: any) => {
|
|
||||||
const [value, setValue] = useState(args.defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComponentName
|
|
||||||
{...args}
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setValue(newValue);
|
|
||||||
args.onChange?.(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: ComponentWithState,
|
|
||||||
args: { defaultValue: "initial" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. **Quality Requirements**
|
|
||||||
- Include component description in parameters.docs
|
|
||||||
- Add story documentation for non-obvious use cases
|
|
||||||
- Test edge cases (overflow, empty states, many elements)
|
|
||||||
- Ensure no TypeScript errors
|
|
||||||
- Use realistic prop values
|
|
||||||
- Include at least 3-5 story variants
|
|
||||||
- Example values need to be in the context of survey application
|
|
||||||
|
|
||||||
### 9. **Naming Conventions**
|
|
||||||
- **Story titles**: "UI/ComponentName"
|
|
||||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
|
||||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
|
||||||
- **Props**: camelCase matching component props
|
|
||||||
|
|
||||||
### 10. **Special Cases**
|
|
||||||
- **Generic components**: Remove `component` from meta if type conflicts
|
|
||||||
- **Form components**: Include Invalid, WithValue stories
|
|
||||||
- **Navigation**: Include ManyItems stories
|
|
||||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
|
||||||
|
|
||||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
|
||||||
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
|||||||
# Enterprise License Key
|
# Enterprise License Key
|
||||||
ENTERPRISE_LICENSE_KEY=
|
ENTERPRISE_LICENSE_KEY=
|
||||||
|
|
||||||
|
# Internal Environment (production, staging) - used for internal staging environment
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
|
||||||
# Automatically assign new users to a specific organization and role within that organization
|
# Automatically assign new users to a specific organization and role within that organization
|
||||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||||
# (Role Management is an Enterprise feature)
|
# (Role Management is an Enterprise feature)
|
||||||
|
|||||||
@@ -111,27 +111,21 @@ jobs:
|
|||||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||||
|
|
||||||
const body = `## 🚨 PR Size Warning
|
const body = '## 🚨 PR Size Warning\n\n' +
|
||||||
|
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||||
|
'### 💡 Suggestions:\n' +
|
||||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||||
|
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||||
### 💡 Suggestions:
|
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||||
- **Split by feature or module** - Break down into logical, independent pieces
|
'### 📊 What was counted:\n' +
|
||||||
- **Create a sequence of PRs** - Each building on the previous one
|
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||||
|
'### 📚 Guidelines:\n' +
|
||||||
### 📊 What was counted:
|
'- **Ideal:** 300-500 lines per PR\n' +
|
||||||
- ✅ Source files, stylesheets, configuration files
|
'- **Warning:** 500-800 lines\n' +
|
||||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||||
|
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||||
### 📚 Guidelines:
|
|
||||||
- **Ideal:** 300-500 lines per PR
|
|
||||||
- **Warning:** 500-800 lines
|
|
||||||
- **Critical:** 800+ lines ⚠️
|
|
||||||
|
|
||||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
|
||||||
|
|
||||||
// Check if we already commented
|
// Check if we already commented
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
|||||||
@@ -62,3 +62,4 @@ branch.json
|
|||||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
.cursorrules
|
.cursorrules
|
||||||
i18n.cache
|
i18n.cache
|
||||||
|
stats.html
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
# Load environment variables from .env files
|
# Load environment variables from .env files
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
set -a
|
set -a
|
||||||
|
|||||||
@@ -18,11 +18,65 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
|
|||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||||
|
We are using SonarQube to identify code smells and security hotspots.
|
||||||
|
|
||||||
|
## Architecture & Patterns
|
||||||
|
|
||||||
|
- Next.js app router lives in `apps/web/app` with route groups like `(app)` and `(auth)`. Services live in `apps/web/lib`, feature modules in `apps/web/modules`.
|
||||||
|
- Server actions wrap service calls and return `{ data }` or `{ error }` consistently.
|
||||||
|
- Context providers should guard against missing provider usage and use cleanup patterns that snapshot refs inside `useEffect` to avoid React hooks warnings
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
- Use React `cache()` for request-level dedupe and `cache.withCache()` or explicit Redis for expensive data.
|
||||||
|
- Do not use Next.js `unstable_cache()`.
|
||||||
|
- Always use `createCacheKey.*` utilities for cache keys.
|
||||||
|
|
||||||
|
## i18n (Internationalization)
|
||||||
|
|
||||||
|
- All user-facing text must use the `t()` function from `react-i18next`.
|
||||||
|
- Key naming: use lowercase with dots for nesting (e.g., `common.welcome`).
|
||||||
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
|
## Database & Prisma Performance
|
||||||
|
|
||||||
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
- Soft Deletion: Check for `isActive` or `deletedAt` fields; use proper filtering.
|
||||||
|
- Never use `skip`/`offset` with `prisma.response.count()`; only use `where`.
|
||||||
|
- Separate count and data queries and run in parallel (`Promise.all`).
|
||||||
|
- Prefer cursor pagination for large datasets.
|
||||||
|
- When filtering by `createdAt`, include indexed fields (e.g., `surveyId` + `createdAt`).
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
||||||
|
|
||||||
|
## Documentation (apps/docs)
|
||||||
|
|
||||||
|
- Add frontmatter with `title`, `description`, and `icon` at the top of the MDX file.
|
||||||
|
- Do not start with an H1; use Camel Case headings (only capitalize the feature name).
|
||||||
|
- Use Mintlify components for steps and callouts.
|
||||||
|
- If Enterprise-only, add the Enterprise note block described in docs.
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
- Stories live in `stories.tsx` in the component folder and import from `"./index"`.
|
||||||
|
- Use `@storybook/react-vite` and organize argTypes into `Behavior`, `Appearance`, `Content`.
|
||||||
|
- Include Default, Disabled (if supported), WithIcon (if supported), all variants, and edge cases.
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
- Always set minimal `permissions` for `GITHUB_TOKEN`.
|
||||||
|
- On `ubuntu-latest`, add `step-security/harden-runner` as the first step.
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
- Keep code DRY and small; remove dead code and unused imports.
|
||||||
|
- Follow React hooks rules, keep effects focused, and avoid unnecessary `useMemo`/`useCallback`.
|
||||||
|
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||||
|
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||||
|
|||||||
+16
-16
@@ -11,24 +11,24 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/survey-ui": "workspace:*",
|
"@formbricks/survey-ui": "workspace:*"
|
||||||
"eslint-plugin-react-refresh": "0.4.24"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.1.3",
|
"@chromatic-com/storybook": "^5.0.0",
|
||||||
"@storybook/addon-a11y": "10.0.8",
|
"@storybook/addon-a11y": "10.1.11",
|
||||||
"@storybook/addon-links": "10.0.8",
|
"@storybook/addon-links": "10.1.11",
|
||||||
"@storybook/addon-onboarding": "10.0.8",
|
"@storybook/addon-onboarding": "10.1.11",
|
||||||
"@storybook/react-vite": "10.0.8",
|
"@storybook/react-vite": "10.1.11",
|
||||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||||
"@tailwindcss/vite": "4.1.17",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@typescript-eslint/parser": "8.48.0",
|
"@typescript-eslint/parser": "8.53.0",
|
||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"esbuild": "0.27.0",
|
"esbuild": "0.27.2",
|
||||||
"eslint-plugin-storybook": "10.0.8",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
|
"eslint-plugin-storybook": "10.1.11",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "10.0.8",
|
"storybook": "10.1.11",
|
||||||
"vite": "7.2.4",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.0.8"
|
"@storybook/addon-docs": "10.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
public/
|
||||||
|
playwright/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
vendor/
|
||||||
@@ -104,6 +104,9 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
|||||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||||
|
|
||||||
|
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||||
|
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingOrganizations && !loadError && (
|
{!isLoadingOrganizations && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{organizations.map((org) => (
|
{organizations.map((org) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={org.id}
|
key={org.id}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingProjects && !loadError && (
|
{!isLoadingProjects && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{projects.map((proj) => (
|
{projects.map((proj) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={proj.id}
|
key={proj.id}
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ShieldCheckIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const SecurityListTip = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||||
|
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("environments.settings.general.security_list_tip")}{" "}
|
||||||
|
<Link
|
||||||
|
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-blue-700">
|
||||||
|
{t("environments.settings.general.security_list_tip_link")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
|||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("environments.settings.general.organization_name")}
|
title={t("environments.settings.general.organization_name")}
|
||||||
description={t("environments.settings.general.organization_name_description")}>
|
description={t("environments.settings.general.organization_name_description")}>
|
||||||
|
|||||||
+1
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
projectCustomScripts={project.customHeadScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage environment={environment} survey={survey} />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
|
|||||||
+21
-1
@@ -3,6 +3,7 @@
|
|||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import {
|
import {
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
|
CodeIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
@@ -18,6 +19,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||||
|
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
|
||||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||||
@@ -51,6 +53,7 @@ interface ShareSurveyModalProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
|
projectCustomScripts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareSurveyModal = ({
|
export const ShareSurveyModal = ({
|
||||||
@@ -65,6 +68,7 @@ export const ShareSurveyModal = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isStorageConfigured,
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
}: ShareSurveyModalProps) => {
|
}: ShareSurveyModalProps) => {
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||||
@@ -191,9 +195,24 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: PrettyUrlTab,
|
componentType: PrettyUrlTab,
|
||||||
componentProps: { publicDomain, isReadOnly },
|
componentProps: { publicDomain, isReadOnly },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: ShareSettingsType.CUSTOM_HTML,
|
||||||
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
|
label: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
|
icon: CodeIcon,
|
||||||
|
title: t("environments.surveys.share.custom_html.nav_title"),
|
||||||
|
description: t("environments.surveys.share.custom_html.description"),
|
||||||
|
componentType: CustomHtmlTab,
|
||||||
|
componentProps: { projectCustomScripts, isReadOnly },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
|
// Filter out tabs that should not be shown on Formbricks Cloud
|
||||||
|
return isFormbricksCloud
|
||||||
|
? tabs.filter(
|
||||||
|
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
|
||||||
|
)
|
||||||
|
: tabs;
|
||||||
}, [
|
}, [
|
||||||
t,
|
t,
|
||||||
survey,
|
survey,
|
||||||
@@ -207,6 +226,7 @@ export const ShareSurveyModal = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
email,
|
email,
|
||||||
isStorageConfigured,
|
isStorageConfigured,
|
||||||
|
projectCustomScripts,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getDefaultActiveId = useCallback(() => {
|
const getDefaultActiveId = useCallback(() => {
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||||
|
|
||||||
|
interface CustomHtmlTabProps {
|
||||||
|
projectCustomScripts: string | null | undefined;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomHtmlFormData {
|
||||||
|
customHeadScripts: string;
|
||||||
|
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<CustomHtmlFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
customHeadScripts: survey.customHeadScripts ?? "",
|
||||||
|
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const scriptsMode = watch("customHeadScriptsMode");
|
||||||
|
|
||||||
|
const onSubmit = async (data: CustomHtmlFormData) => {
|
||||||
|
if (isSaving || isReadOnly) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const updatedSurvey: TSurvey = {
|
||||||
|
...survey,
|
||||||
|
customHeadScripts: data.customHeadScripts || null,
|
||||||
|
customHeadScriptsMode: data.customHeadScriptsMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateSurveyAction(updatedSurvey);
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
|
||||||
|
reset(data);
|
||||||
|
} else {
|
||||||
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
|
||||||
|
<TabToggle
|
||||||
|
id="custom-scripts-mode"
|
||||||
|
options={[
|
||||||
|
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
|
||||||
|
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
|
||||||
|
]}
|
||||||
|
defaultSelected={scriptsMode ?? "add"}
|
||||||
|
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{scriptsMode === "add"
|
||||||
|
? t("environments.surveys.share.custom_html.add_mode_description")
|
||||||
|
: t("environments.surveys.share.custom_html.replace_mode_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Scripts Preview */}
|
||||||
|
{projectCustomScripts && (
|
||||||
|
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||||
|
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||||
|
{projectCustomScripts}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!projectCustomScripts && (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Survey Scripts */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customHeadScripts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.survey_scripts_description")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||||
|
className={cn(
|
||||||
|
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||||
|
{isSaving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{/* Security Warning */}
|
||||||
|
<Alert variant="warning" className="flex items-start gap-2">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("environments.surveys.share.custom_html.security_warning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -13,6 +13,7 @@ export enum ShareViaType {
|
|||||||
export enum ShareSettingsType {
|
export enum ShareSettingsType {
|
||||||
LINK_SETTINGS = "link-settings",
|
LINK_SETTINGS = "link-settings",
|
||||||
PRETTY_URL = "pretty-url",
|
PRETTY_URL = "pretty-url",
|
||||||
|
CUSTOM_HTML = "custom-html",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LinkTabsType {
|
export enum LinkTabsType {
|
||||||
|
|||||||
+3
-1
@@ -21,6 +21,7 @@ import {
|
|||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
MousePointerClickIcon,
|
MousePointerClickIcon,
|
||||||
|
NetworkIcon,
|
||||||
PieChartIcon,
|
PieChartIcon,
|
||||||
Rows3Icon,
|
Rows3Icon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
|||||||
action: MousePointerClickIcon,
|
action: MousePointerClickIcon,
|
||||||
country: FlagIcon,
|
country: FlagIcon,
|
||||||
url: LinkIcon,
|
url: LinkIcon,
|
||||||
|
ipAddress: NetworkIcon,
|
||||||
|
|
||||||
// others
|
// others
|
||||||
Language: LanguagesIcon,
|
Language: LanguagesIcon,
|
||||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={setInputValue}
|
onValueChange={setInputValue}
|
||||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const mockPipelineInput = {
|
|||||||
},
|
},
|
||||||
country: "USA",
|
country: "USA",
|
||||||
action: "Action Name",
|
action: "Action Name",
|
||||||
|
ipAddress: "203.0.113.7",
|
||||||
} as TResponseMeta,
|
} as TResponseMeta,
|
||||||
personAttributes: {},
|
personAttributes: {},
|
||||||
singleUseId: null,
|
singleUseId: null,
|
||||||
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
|
|||||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||||
// Adjust expectations for metadata and recalled question
|
// Adjust expectations for metadata and recalled question
|
||||||
const expectedMetadataString =
|
const expectedMetadataString =
|
||||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
|
||||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||||
mockAirtableIntegration.config.key,
|
mockAirtableIntegration.config.key,
|
||||||
mockAirtableIntegration.config.data[0],
|
mockAirtableIntegration.config.data[0],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
|||||||
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
||||||
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
||||||
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
||||||
|
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
|
||||||
|
|
||||||
// Join all the elements in the result array with a newline for formatting
|
// Join all the elements in the result array with a newline for formatting
|
||||||
return result.join("\n");
|
return result.join("\n");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { CRON_SECRET } from "@/lib/constants";
|
import { CRON_SECRET } from "@/lib/constants";
|
||||||
|
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const webhookPromises = webhooks.map((webhook) =>
|
const webhookPromises = webhooks.map((webhook) => {
|
||||||
fetchWithTimeout(webhook.url, {
|
const body = JSON.stringify({
|
||||||
method: "POST",
|
webhookId: webhook.id,
|
||||||
headers: { "content-type": "application/json" },
|
event,
|
||||||
body: JSON.stringify({
|
data: {
|
||||||
webhookId: webhook.id,
|
...response,
|
||||||
event,
|
survey: {
|
||||||
data: {
|
title: survey.name,
|
||||||
...response,
|
type: survey.type,
|
||||||
survey: {
|
status: survey.status,
|
||||||
title: survey.name,
|
createdAt: survey.createdAt,
|
||||||
type: survey.type,
|
updatedAt: survey.updatedAt,
|
||||||
status: survey.status,
|
|
||||||
createdAt: survey.createdAt,
|
|
||||||
updatedAt: survey.updatedAt,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate Standard Webhooks headers
|
||||||
|
const webhookMessageId = uuidv7();
|
||||||
|
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"webhook-id": webhookMessageId,
|
||||||
|
"webhook-timestamp": webhookTimestamp.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add signature if webhook has a secret configured
|
||||||
|
if (webhook.secret) {
|
||||||
|
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||||
|
webhookMessageId,
|
||||||
|
webhookTimestamp,
|
||||||
|
body,
|
||||||
|
webhook.secret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchWithTimeout(webhook.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: requestHeaders,
|
||||||
|
body,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
if (event === "responseFinished") {
|
if (event === "responseFinished") {
|
||||||
// Fetch integrations and responseCount in parallel
|
// Fetch integrations and responseCount in parallel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
|||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
|
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
|
|||||||
action: responseInputData?.meta?.action,
|
action: responseInputData?.meta?.action,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture IP address if the survey has IP capture enabled
|
||||||
|
// Server-derived IP always overwrites any client-provided value
|
||||||
|
if (survey.isCaptureIpEnabled) {
|
||||||
|
const ipAddress = await getClientIpFromHeaders();
|
||||||
|
meta.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
response = await createResponseWithQuotaEvaluation({
|
response = await createResponseWithQuotaEvaluation({
|
||||||
...responseInputData,
|
...responseInputData,
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
|
|||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/crypto", () => ({
|
||||||
|
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("createWebhook", () => {
|
describe("createWebhook", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
|
|||||||
source: webhookInput.source,
|
source: webhookInput.source,
|
||||||
surveyIds: webhookInput.surveyIds,
|
surveyIds: webhookInput.surveyIds,
|
||||||
triggers: webhookInput.triggers,
|
triggers: webhookInput.triggers,
|
||||||
|
secret: "whsec_test_secret_1234567890",
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: webhookInput.environmentId,
|
id: webhookInput.environmentId,
|
||||||
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
|
|||||||
source: webhookInput.source,
|
source: webhookInput.source,
|
||||||
surveyIds: webhookInput.surveyIds,
|
surveyIds: webhookInput.surveyIds,
|
||||||
triggers: webhookInput.triggers,
|
triggers: webhookInput.triggers,
|
||||||
|
secret: "whsec_test_secret_1234567890",
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: webhookInput.environmentId,
|
id: webhookInput.environmentId,
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
|||||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||||
|
import { generateWebhookSecret } from "@/lib/crypto";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
|
||||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||||
validateInputs([webhookInput, ZWebhookInput]);
|
validateInputs([webhookInput, ZWebhookInput]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
|
||||||
const createdWebhook = await prisma.webhook.create({
|
const createdWebhook = await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
url: webhookInput.url,
|
url: webhookInput.url,
|
||||||
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
|||||||
source: webhookInput.source,
|
source: webhookInput.source,
|
||||||
surveyIds: webhookInput.surveyIds || [],
|
surveyIds: webhookInput.surveyIds || [],
|
||||||
triggers: webhookInput.triggers || [],
|
triggers: webhookInput.triggers || [],
|
||||||
|
secret,
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: webhookInput.environmentId,
|
id: webhookInput.environmentId,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
|||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
|
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
action: responseInputData?.meta?.action,
|
action: responseInputData?.meta?.action,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture IP address if the survey has IP capture enabled
|
||||||
|
// Server-derived IP always overwrites any client-provided value
|
||||||
|
if (survey.isCaptureIpEnabled) {
|
||||||
|
const ipAddress = await getClientIpFromHeaders();
|
||||||
|
meta.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
response = await createResponseWithQuotaEvaluation({
|
response = await createResponseWithQuotaEvaluation({
|
||||||
...responseInputData,
|
...responseInputData,
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
|
import { createI18nString } from "@/lib/i18n/utils";
|
||||||
|
import { buildBlock } from "./survey-block-builder";
|
||||||
|
|
||||||
|
const mockT = vi.fn((key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
"common.next": "Next",
|
||||||
|
"common.back": "Back",
|
||||||
|
"": "",
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
|
describe("survey-block-builder", () => {
|
||||||
|
describe("buildBlock", () => {
|
||||||
|
const mockElements = [
|
||||||
|
{
|
||||||
|
id: "element-1",
|
||||||
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
|
headline: createI18nString("Test Question", []),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
longAnswer: false,
|
||||||
|
charLimit: { enabled: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("should use getDefaultButtonLabel when buttonLabel is provided", () => {
|
||||||
|
const result = buildBlock({
|
||||||
|
name: "Test Block",
|
||||||
|
elements: mockElements,
|
||||||
|
buttonLabel: "Custom Next",
|
||||||
|
t: mockT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.buttonLabel).toEqual({
|
||||||
|
default: "Custom Next",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use createI18nString with empty translation when buttonLabel is not provided", () => {
|
||||||
|
const result = buildBlock({
|
||||||
|
name: "Test Block",
|
||||||
|
elements: mockElements,
|
||||||
|
t: mockT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.buttonLabel).toEqual({
|
||||||
|
default: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use getDefaultBackButtonLabel when backButtonLabel is provided", () => {
|
||||||
|
const result = buildBlock({
|
||||||
|
name: "Test Block",
|
||||||
|
elements: mockElements,
|
||||||
|
backButtonLabel: "Custom Back",
|
||||||
|
t: mockT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.backButtonLabel).toEqual({
|
||||||
|
default: "Custom Back",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use createI18nString with empty translation when backButtonLabel is not provided", () => {
|
||||||
|
const result = buildBlock({
|
||||||
|
name: "Test Block",
|
||||||
|
elements: mockElements,
|
||||||
|
t: mockT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.backButtonLabel).toEqual({
|
||||||
|
default: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -302,7 +302,9 @@ export const buildBlock = ({
|
|||||||
elements,
|
elements,
|
||||||
logic,
|
logic,
|
||||||
logicFallback,
|
logicFallback,
|
||||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : createI18nString(t(""), []),
|
||||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
backButtonLabel: backButtonLabel
|
||||||
|
? getDefaultBackButtonLabel(backButtonLabel, t)
|
||||||
|
: createI18nString(t(""), []),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
segment: null,
|
segment: null,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje1",
|
||||||
name: "Block 1",
|
name: "Block 1",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: "cltxxaa6x0000g8hacxdxeje2",
|
||||||
name: "Block 2",
|
name: "Block 2",
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
showLanguageSwitch: false,
|
showLanguageSwitch: false,
|
||||||
followUps: [],
|
followUps: [],
|
||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
|
isCaptureIpEnabled: false,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||||
slug: null,
|
slug: null,
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ checksums:
|
|||||||
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
|
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
|
||||||
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
|
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
|
||||||
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||||
|
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
|
||||||
|
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
|
||||||
|
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
|
||||||
|
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
|
||||||
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||||
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
|
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
|
||||||
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
|
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
|
||||||
@@ -170,6 +174,7 @@ checksums:
|
|||||||
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||||
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||||
common/domain: 402d46965eacc3af4c5df92e53e95712
|
common/domain: 402d46965eacc3af4c5df92e53e95712
|
||||||
|
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||||
@@ -736,20 +741,26 @@ checksums:
|
|||||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||||
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
|
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
|
||||||
|
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
|
||||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||||
|
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||||
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
|
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
|
||||||
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
|
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
|
||||||
|
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
|
||||||
|
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
|
||||||
|
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
|
||||||
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
|
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
|
||||||
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
|
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
|
||||||
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
|
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
|
||||||
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
|
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
|
||||||
|
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
|
||||||
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
|
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
|
||||||
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
|
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
|
||||||
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
|
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
|
||||||
@@ -947,6 +958,8 @@ checksums:
|
|||||||
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
|
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
|
||||||
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
|
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
|
||||||
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
|
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
|
||||||
|
environments/settings/general/security_list_tip: 0bbed89fa5265da7e07767087f87c736
|
||||||
|
environments/settings/general/security_list_tip_link: ccdb1a21610ebf5a626d813b155be4ba
|
||||||
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
|
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
|
||||||
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
|
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
|
||||||
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
|
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
|
||||||
@@ -1098,6 +1111,9 @@ checksums:
|
|||||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||||
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||||
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
||||||
|
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||||
|
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||||
|
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||||
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
|
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
|
||||||
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
|
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
|
||||||
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
||||||
@@ -1119,6 +1135,8 @@ checksums:
|
|||||||
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
||||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||||
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
||||||
|
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
||||||
|
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
|
||||||
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
||||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||||
@@ -1377,6 +1395,7 @@ checksums:
|
|||||||
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
||||||
environments/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
|
environments/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
|
||||||
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
|
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
|
||||||
|
environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
|
||||||
environments/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
|
environments/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
|
||||||
environments/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
|
environments/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
|
||||||
environments/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
|
environments/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
|
||||||
@@ -1569,6 +1588,7 @@ checksums:
|
|||||||
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
|
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
|
||||||
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
|
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||||
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
|
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
|
||||||
|
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
|
||||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||||
@@ -1609,6 +1629,20 @@ checksums:
|
|||||||
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
|
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
|
||||||
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
|
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
|
||||||
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
|
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
|
||||||
|
environments/surveys/share/custom_html/add_mode_description: f48dcf53bce27cc40c3546547e8395cb
|
||||||
|
environments/surveys/share/custom_html/add_to_workspace: af9cd24872f25cfc4231b926acc76d7c
|
||||||
|
environments/surveys/share/custom_html/description: 0634048655de8b4b17b41d496e1ea457
|
||||||
|
environments/surveys/share/custom_html/nav_title: 01f993f027ab277058eacb8a48ea7c01
|
||||||
|
environments/surveys/share/custom_html/no_workspace_scripts: 7fc57f576c98e96ee73e7b489345d51a
|
||||||
|
environments/surveys/share/custom_html/placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||||
|
environments/surveys/share/custom_html/replace_mode_description: 6eaf17275c02b0d5ac21255747f36271
|
||||||
|
environments/surveys/share/custom_html/replace_workspace: b80e698cc8790246fea42453bfa4b09d
|
||||||
|
environments/surveys/share/custom_html/saved_successfully: 14e7d2d646803ac1dd24cfa45c22606c
|
||||||
|
environments/surveys/share/custom_html/script_mode: 60ed1102dd42ad14e272df5f6921b423
|
||||||
|
environments/surveys/share/custom_html/security_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||||
|
environments/surveys/share/custom_html/survey_scripts_description: 948746d51db23b348164105c175391b3
|
||||||
|
environments/surveys/share/custom_html/survey_scripts_label: 095d9fe768abe2bb32428184ee1c9b5a
|
||||||
|
environments/surveys/share/custom_html/workspace_scripts_label: 3d9b6c09eae10a2bacb3ac96b4db4a19
|
||||||
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
|
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
|
||||||
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
|
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
|
||||||
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
|
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
|
||||||
@@ -1820,6 +1854,13 @@ checksums:
|
|||||||
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
||||||
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
||||||
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
|
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
|
||||||
|
environments/workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb
|
||||||
|
environments/workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1
|
||||||
|
environments/workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6
|
||||||
|
environments/workspace/general/custom_scripts_label: 3b189dd62ae0cc35d616e04af90f0b38
|
||||||
|
environments/workspace/general/custom_scripts_placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||||
|
environments/workspace/general/custom_scripts_updated_successfully: eabe8e6ededa86342d59093fe308c681
|
||||||
|
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||||
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
|
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
|
||||||
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
||||||
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
|
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
|
||||||
|
|||||||
+143
-13
@@ -1,8 +1,11 @@
|
|||||||
import * as crypto from "crypto";
|
import * as crypto from "node:crypto";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
// Import after unmocking
|
// Import after unmocking
|
||||||
import {
|
import {
|
||||||
|
generateStandardWebhookSignature,
|
||||||
|
generateWebhookSecret,
|
||||||
|
getWebhookSecretBytes,
|
||||||
hashSecret,
|
hashSecret,
|
||||||
hashSha256,
|
hashSha256,
|
||||||
parseApiKeyV2,
|
parseApiKeyV2,
|
||||||
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Webhook Signature Functions", () => {
|
||||||
|
describe("generateWebhookSecret", () => {
|
||||||
|
test("should generate a secret with whsec_ prefix", () => {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
expect(secret.startsWith("whsec_")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate base64-encoded content after prefix", () => {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
const base64Part = secret.slice(6); // Remove "whsec_"
|
||||||
|
|
||||||
|
// Should be valid base64
|
||||||
|
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||||
|
|
||||||
|
// Should decode to 32 bytes (256 bits)
|
||||||
|
const decoded = Buffer.from(base64Part, "base64");
|
||||||
|
expect(decoded.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate unique secrets each time", () => {
|
||||||
|
const secret1 = generateWebhookSecret();
|
||||||
|
const secret2 = generateWebhookSecret();
|
||||||
|
expect(secret1).not.toBe(secret2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWebhookSecretBytes", () => {
|
||||||
|
test("should decode whsec_ prefixed secret to bytes", () => {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
const bytes = getWebhookSecretBytes(secret);
|
||||||
|
|
||||||
|
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||||
|
expect(bytes.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle secret without whsec_ prefix", () => {
|
||||||
|
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
|
||||||
|
const bytes = getWebhookSecretBytes(base64Secret);
|
||||||
|
|
||||||
|
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||||
|
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly decode a known secret", () => {
|
||||||
|
// Create a known secret
|
||||||
|
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
|
||||||
|
const secret = `whsec_${knownBytes.toString("base64")}`;
|
||||||
|
|
||||||
|
const decoded = getWebhookSecretBytes(secret);
|
||||||
|
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateStandardWebhookSignature", () => {
|
||||||
|
test("should generate signature in v1,{base64} format", () => {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
|
||||||
|
|
||||||
|
expect(signature.startsWith("v1,")).toBe(true);
|
||||||
|
const base64Part = signature.slice(3);
|
||||||
|
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate deterministic signatures for same inputs", () => {
|
||||||
|
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||||
|
const webhookId = "msg_test123";
|
||||||
|
const timestamp = 1704547200;
|
||||||
|
const payload = '{"event":"test"}';
|
||||||
|
|
||||||
|
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||||
|
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||||
|
|
||||||
|
expect(sig1).toBe(sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate different signatures for different payloads", () => {
|
||||||
|
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||||
|
const webhookId = "msg_test123";
|
||||||
|
const timestamp = 1704547200;
|
||||||
|
|
||||||
|
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
|
||||||
|
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
|
||||||
|
|
||||||
|
expect(sig1).not.toBe(sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate different signatures for different timestamps", () => {
|
||||||
|
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||||
|
const webhookId = "msg_test123";
|
||||||
|
const payload = '{"event":"test"}';
|
||||||
|
|
||||||
|
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
|
||||||
|
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
|
||||||
|
|
||||||
|
expect(sig1).not.toBe(sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should generate different signatures for different webhook IDs", () => {
|
||||||
|
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||||
|
const timestamp = 1704547200;
|
||||||
|
const payload = '{"event":"test"}';
|
||||||
|
|
||||||
|
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
|
||||||
|
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
|
||||||
|
|
||||||
|
expect(sig1).not.toBe(sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should produce verifiable signatures", () => {
|
||||||
|
// This test verifies the signature can be verified using the same algorithm
|
||||||
|
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
|
||||||
|
const secret = `whsec_${secretBytes.toString("base64")}`;
|
||||||
|
const webhookId = "msg_verify";
|
||||||
|
const timestamp = 1704547200;
|
||||||
|
const payload = '{"event":"verify"}';
|
||||||
|
|
||||||
|
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||||
|
|
||||||
|
// Manually compute the expected signature
|
||||||
|
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||||
|
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||||
|
|
||||||
|
expect(signature).toBe(`v1,${expectedSig}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GCM decryption failure logging", () => {
|
describe("GCM decryption failure logging", () => {
|
||||||
// Test key - 32 bytes for AES-256
|
// Test key - 32 bytes for AES-256
|
||||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
@@ -314,11 +444,11 @@ describe("Crypto Utils", () => {
|
|||||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||||
|
|
||||||
// Verify logger.warn was called with the correct format (object first, message second)
|
// Verify logger.warn was called with the correct format (object first, message second)
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
|
||||||
{ err: expect.any(Error) },
|
|
||||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
|
||||||
);
|
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||||
|
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||||
|
expect(firstArg).toHaveProperty("err");
|
||||||
|
expect(firstArg.err).toHaveProperty("message");
|
||||||
|
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||||
@@ -342,11 +472,11 @@ describe("Crypto Utils", () => {
|
|||||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||||
|
|
||||||
// Verify logger.warn was called
|
// Verify logger.warn was called
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
|
||||||
{ err: expect.any(Error) },
|
|
||||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
|
||||||
);
|
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||||
|
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||||
|
expect(firstArg).toHaveProperty("err");
|
||||||
|
expect(firstArg.err).toHaveProperty("message");
|
||||||
|
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||||
@@ -366,11 +496,11 @@ describe("Crypto Utils", () => {
|
|||||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||||
|
|
||||||
// Verify logger.warn was called
|
// Verify logger.warn was called
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
|
||||||
{ err: expect.any(Error) },
|
|
||||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
|
||||||
);
|
|
||||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||||
|
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||||
|
expect(firstArg).toHaveProperty("err");
|
||||||
|
expect(firstArg.err).toHaveProperty("message");
|
||||||
|
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+52
-1
@@ -1,5 +1,5 @@
|
|||||||
import { compare, hash } from "bcryptjs";
|
import { compare, hash } from "bcryptjs";
|
||||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||||
|
|
||||||
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
|||||||
|
|
||||||
return { secret };
|
return { secret };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Standard Webhooks secret prefix
|
||||||
|
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Standard Webhooks compliant secret
|
||||||
|
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||||
|
*
|
||||||
|
* Format: whsec_ + base64(32 random bytes)
|
||||||
|
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
|
||||||
|
*/
|
||||||
|
export const generateWebhookSecret = (): string => {
|
||||||
|
const secretBytes = randomBytes(32); // 256 bits of entropy
|
||||||
|
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a Standard Webhooks secret to get the raw bytes
|
||||||
|
* Strips the whsec_ prefix and base64 decodes the rest
|
||||||
|
*
|
||||||
|
* @param secret The webhook secret (with or without whsec_ prefix)
|
||||||
|
* @returns Buffer containing the raw secret bytes
|
||||||
|
*/
|
||||||
|
export const getWebhookSecretBytes = (secret: string): Buffer => {
|
||||||
|
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
||||||
|
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
||||||
|
: secret;
|
||||||
|
return Buffer.from(base64Part, "base64");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Standard Webhooks compliant signature
|
||||||
|
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||||
|
*
|
||||||
|
* @param webhookId Unique message identifier
|
||||||
|
* @param timestamp Unix timestamp in seconds
|
||||||
|
* @param payload The request body as a string
|
||||||
|
* @param secret The shared secret (whsec_ prefixed)
|
||||||
|
* @returns The signature in format "v1,{base64_signature}"
|
||||||
|
*/
|
||||||
|
export const generateStandardWebhookSignature = (
|
||||||
|
webhookId: string,
|
||||||
|
timestamp: number,
|
||||||
|
payload: string,
|
||||||
|
secret: string
|
||||||
|
): string => {
|
||||||
|
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||||
|
const secretBytes = getWebhookSecretBytes(secret);
|
||||||
|
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||||
|
return `v1,${signature}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const env = createEnv({
|
|||||||
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||||
ENCRYPTION_KEY: z.string(),
|
ENCRYPTION_KEY: z.string(),
|
||||||
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
||||||
|
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
|
||||||
GITHUB_ID: z.string().optional(),
|
GITHUB_ID: z.string().optional(),
|
||||||
GITHUB_SECRET: z.string().optional(),
|
GITHUB_SECRET: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
@@ -151,6 +152,7 @@ export const env = createEnv({
|
|||||||
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
|
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
|
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
|
||||||
|
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||||
GITHUB_ID: process.env.GITHUB_ID,
|
GITHUB_ID: process.env.GITHUB_ID,
|
||||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
|||||||
+7
-146
@@ -88,7 +88,7 @@ export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode
|
|||||||
return language?.default ? "default" : language?.language.code || "default";
|
return language?.default ? "default" : language?.language.code || "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iso639Identifiers = iso639Languages.map((language) => language.alpha2);
|
export const iso639Identifiers = iso639Languages.map((language) => language.code);
|
||||||
|
|
||||||
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
||||||
// Iterates over the object recursively and adds empty strings for new language keys
|
// Iterates over the object recursively and adds empty strings for new language keys
|
||||||
@@ -130,217 +130,78 @@ export const appLanguages = [
|
|||||||
code: "en-US",
|
code: "en-US",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "English (US)",
|
"en-US": "English (US)",
|
||||||
"de-DE": "Englisch (US)",
|
|
||||||
"pt-BR": "Inglês (EUA)",
|
|
||||||
"fr-FR": "Anglais (États-Unis)",
|
|
||||||
"zh-Hant-TW": "英文 (美國)",
|
|
||||||
"pt-PT": "Inglês (EUA)",
|
|
||||||
"ro-RO": "Engleză (SUA)",
|
|
||||||
"ja-JP": "英語(米国)",
|
|
||||||
"zh-Hans-CN": "英语(美国)",
|
|
||||||
"nl-NL": "Engels (VS)",
|
|
||||||
"es-ES": "Inglés (EE.UU.)",
|
|
||||||
"sv-SE": "Engelska (USA)",
|
|
||||||
"ru-RU": "Английский (США)",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "de-DE",
|
code: "de-DE",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "German",
|
"en-US": "German",
|
||||||
"de-DE": "Deutsch",
|
|
||||||
"pt-BR": "Alemão",
|
|
||||||
"fr-FR": "Allemand",
|
|
||||||
"zh-Hant-TW": "德語",
|
|
||||||
"pt-PT": "Alemão",
|
|
||||||
"ro-RO": "Germană",
|
|
||||||
"ja-JP": "ドイツ語",
|
|
||||||
"zh-Hans-CN": "德语",
|
|
||||||
"nl-NL": "Duits",
|
|
||||||
"es-ES": "Alemán",
|
|
||||||
"sv-SE": "Tyska",
|
|
||||||
"ru-RU": "Немецкий",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "pt-BR",
|
code: "pt-BR",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Portuguese (Brazil)",
|
"en-US": "Portuguese (Brazil)",
|
||||||
"de-DE": "Portugiesisch (Brasilien)",
|
|
||||||
"pt-BR": "Português (Brasil)",
|
|
||||||
"fr-FR": "Portugais (Brésil)",
|
|
||||||
"zh-Hant-TW": "葡萄牙語 (巴西)",
|
|
||||||
"pt-PT": "Português (Brasil)",
|
|
||||||
"ro-RO": "Portugheză (Brazilia)",
|
|
||||||
"ja-JP": "ポルトガル語(ブラジル)",
|
|
||||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
|
||||||
"nl-NL": "Portugees (Brazilië)",
|
|
||||||
"es-ES": "Portugués (Brasil)",
|
|
||||||
"sv-SE": "Portugisiska (Brasilien)",
|
|
||||||
"ru-RU": "Португальский (Бразилия)",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "fr-FR",
|
code: "fr-FR",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "French",
|
"en-US": "French",
|
||||||
"de-DE": "Französisch",
|
|
||||||
"pt-BR": "Francês",
|
|
||||||
"fr-FR": "Français",
|
|
||||||
"zh-Hant-TW": "法語",
|
|
||||||
"pt-PT": "Francês",
|
|
||||||
"ro-RO": "Franceză",
|
|
||||||
"ja-JP": "フランス語",
|
|
||||||
"zh-Hans-CN": "法语",
|
|
||||||
"nl-NL": "Frans",
|
|
||||||
"es-ES": "Francés",
|
|
||||||
"sv-SE": "Franska",
|
|
||||||
"ru-RU": "Французский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "zh-Hant-TW",
|
code: "zh-Hant-TW",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Chinese (Traditional)",
|
"en-US": "Chinese (Traditional)",
|
||||||
"de-DE": "Chinesisch (Traditionell)",
|
|
||||||
"pt-BR": "Chinês (Tradicional)",
|
|
||||||
"fr-FR": "Chinois (Traditionnel)",
|
|
||||||
"zh-Hant-TW": "繁體中文",
|
|
||||||
"pt-PT": "Chinês (Tradicional)",
|
|
||||||
"ro-RO": "Chineza (Tradițională)",
|
|
||||||
"ja-JP": "中国語(繁体字)",
|
|
||||||
"zh-Hans-CN": "繁体中文",
|
|
||||||
"nl-NL": "Chinees (Traditioneel)",
|
|
||||||
"es-ES": "Chino (Tradicional)",
|
|
||||||
"sv-SE": "Kinesiska (traditionell)",
|
|
||||||
"ru-RU": "Китайский (традиционный)",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "pt-PT",
|
code: "pt-PT",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Portuguese (Portugal)",
|
"en-US": "Portuguese (Portugal)",
|
||||||
"de-DE": "Portugiesisch (Portugal)",
|
|
||||||
"pt-BR": "Português (Portugal)",
|
|
||||||
"fr-FR": "Portugais (Portugal)",
|
|
||||||
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
|
|
||||||
"pt-PT": "Português (Portugal)",
|
|
||||||
"ro-RO": "Portugheză (Portugalia)",
|
|
||||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
|
||||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
|
||||||
"nl-NL": "Portugees (Portugal)",
|
|
||||||
"es-ES": "Portugués (Portugal)",
|
|
||||||
"sv-SE": "Portugisiska (Portugal)",
|
|
||||||
"ru-RU": "Португальский (Португалия)",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "ro-RO",
|
code: "ro-RO",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Romanian",
|
"en-US": "Romanian",
|
||||||
"de-DE": "Rumänisch",
|
|
||||||
"pt-BR": "Romeno",
|
|
||||||
"fr-FR": "Roumain",
|
|
||||||
"zh-Hant-TW": "羅馬尼亞語",
|
|
||||||
"pt-PT": "Romeno",
|
|
||||||
"ro-RO": "Română",
|
|
||||||
"ja-JP": "ルーマニア語",
|
|
||||||
"zh-Hans-CN": "罗马尼亚语",
|
|
||||||
"nl-NL": "Roemeens",
|
|
||||||
"es-ES": "Rumano",
|
|
||||||
"sv-SE": "Rumänska",
|
|
||||||
"ru-RU": "Румынский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "ja-JP",
|
code: "ja-JP",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Japanese",
|
"en-US": "Japanese",
|
||||||
"de-DE": "Japanisch",
|
|
||||||
"pt-BR": "Japonês",
|
|
||||||
"fr-FR": "Japonais",
|
|
||||||
"zh-Hant-TW": "日語",
|
|
||||||
"pt-PT": "Japonês",
|
|
||||||
"ro-RO": "Japoneză",
|
|
||||||
"ja-JP": "日本語",
|
|
||||||
"zh-Hans-CN": "日语",
|
|
||||||
"nl-NL": "Japans",
|
|
||||||
"es-ES": "Japonés",
|
|
||||||
"sv-SE": "Japanska",
|
|
||||||
"ru-RU": "Японский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "zh-Hans-CN",
|
code: "zh-Hans-CN",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Chinese (Simplified)",
|
"en-US": "Chinese (Simplified)",
|
||||||
"de-DE": "Chinesisch (Vereinfacht)",
|
|
||||||
"pt-BR": "Chinês (Simplificado)",
|
|
||||||
"fr-FR": "Chinois (Simplifié)",
|
|
||||||
"zh-Hant-TW": "簡體中文",
|
|
||||||
"pt-PT": "Chinês (Simplificado)",
|
|
||||||
"ro-RO": "Chineza (Simplificată)",
|
|
||||||
"ja-JP": "中国語(簡体字)",
|
|
||||||
"zh-Hans-CN": "简体中文",
|
|
||||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
|
||||||
"es-ES": "Chino (Simplificado)",
|
|
||||||
"sv-SE": "Kinesiska (förenklad)",
|
|
||||||
"ru-RU": "Китайский (упрощенный)",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "nl-NL",
|
code: "nl-NL",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Dutch",
|
"en-US": "Dutch",
|
||||||
"de-DE": "Niederländisch",
|
|
||||||
"pt-BR": "Holandês",
|
|
||||||
"fr-FR": "Néerlandais",
|
|
||||||
"zh-Hant-TW": "荷蘭語",
|
|
||||||
"pt-PT": "Holandês",
|
|
||||||
"ro-RO": "Olandeza",
|
|
||||||
"ja-JP": "オランダ語",
|
|
||||||
"zh-Hans-CN": "荷兰语",
|
|
||||||
"nl-NL": "Nederlands",
|
|
||||||
"es-ES": "Neerlandés",
|
|
||||||
"sv-SE": "Nederländska",
|
|
||||||
"ru-RU": "Голландский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "es-ES",
|
code: "es-ES",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Spanish",
|
"en-US": "Spanish",
|
||||||
"de-DE": "Spanisch",
|
|
||||||
"pt-BR": "Espanhol",
|
|
||||||
"fr-FR": "Espagnol",
|
|
||||||
"zh-Hant-TW": "西班牙語",
|
|
||||||
"pt-PT": "Espanhol",
|
|
||||||
"ro-RO": "Spaniol",
|
|
||||||
"ja-JP": "スペイン語",
|
|
||||||
"zh-Hans-CN": "西班牙语",
|
|
||||||
"nl-NL": "Spaans",
|
|
||||||
"es-ES": "Español",
|
|
||||||
"sv-SE": "Spanska",
|
|
||||||
"ru-RU": "Испанский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "sv-SE",
|
code: "sv-SE",
|
||||||
label: {
|
label: {
|
||||||
"en-US": "Swedish",
|
"en-US": "Swedish",
|
||||||
"de-DE": "Schwedisch",
|
},
|
||||||
"pt-BR": "Sueco",
|
},
|
||||||
"fr-FR": "Suédois",
|
{
|
||||||
"zh-Hant-TW": "瑞典語",
|
code: "ru-RU",
|
||||||
"pt-PT": "Sueco",
|
label: {
|
||||||
"ro-RO": "Suedeză",
|
"en-US": "Russian",
|
||||||
"ja-JP": "スウェーデン語",
|
|
||||||
"zh-Hans-CN": "瑞典语",
|
|
||||||
"nl-NL": "Zweeds",
|
|
||||||
"es-ES": "Sueco",
|
|
||||||
"sv-SE": "Svenska",
|
|
||||||
"ru-RU": "Шведский",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
export { iso639Languages };
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type TInstanceInfo = {
|
|||||||
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
|
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
|
||||||
try {
|
try {
|
||||||
const oldestOrg = await prisma.organization.findFirst({
|
const oldestOrg = await prisma.organization.findFirst({
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
select: { id: true, createdAt: true },
|
select: { id: true, createdAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const selectProject = {
|
|||||||
environments: true,
|
environments: true,
|
||||||
styling: true,
|
styling: true,
|
||||||
logo: true,
|
logo: true,
|
||||||
|
customHeadScripts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserProjects = reactCache(
|
export const getUserProjects = reactCache(
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
|
isCaptureIpEnabled: false,
|
||||||
endings: [
|
endings: [
|
||||||
{
|
{
|
||||||
id: "umyknohldc7w26ocjdhaa62c",
|
id: "umyknohldc7w26ocjdhaa62c",
|
||||||
@@ -268,6 +269,8 @@ export const mockSyncSurveyOutput: SurveyMock = {
|
|||||||
showLanguageSwitch: null,
|
showLanguageSwitch: null,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
slug: null,
|
slug: null,
|
||||||
|
customHeadScripts: null,
|
||||||
|
customHeadScriptsMode: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockSurveyOutput: SurveyMock = {
|
export const mockSurveyOutput: SurveyMock = {
|
||||||
@@ -292,6 +295,8 @@ export const mockSurveyOutput: SurveyMock = {
|
|||||||
showLanguageSwitch: null,
|
showLanguageSwitch: null,
|
||||||
...baseSurveyProperties,
|
...baseSurveyProperties,
|
||||||
slug: null,
|
slug: null,
|
||||||
|
customHeadScripts: null,
|
||||||
|
customHeadScriptsMode: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSurveyInput: TSurveyCreateInput = {
|
export const createSurveyInput: TSurveyCreateInput = {
|
||||||
@@ -322,6 +327,8 @@ export const updateSurveyInput: TSurvey = {
|
|||||||
...baseSurveyProperties,
|
...baseSurveyProperties,
|
||||||
...commonMockProperties,
|
...commonMockProperties,
|
||||||
slug: null,
|
slug: null,
|
||||||
|
customHeadScripts: null,
|
||||||
|
customHeadScriptsMode: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockTransformedSurveyOutput = {
|
export const mockTransformedSurveyOutput = {
|
||||||
@@ -574,4 +581,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
|||||||
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
|
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
|
||||||
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
|
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
|
||||||
],
|
],
|
||||||
|
customHeadScripts: null,
|
||||||
|
customHeadScriptsMode: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -308,6 +308,10 @@ describe("Tests for updateSurvey", () => {
|
|||||||
const updatedSurvey = await updateSurvey(updateSurveyInput);
|
const updatedSurvey = await updateSurvey(updateSurveyInput);
|
||||||
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
|
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: Language handling tests (for languages.length > 0 fix) are covered in
|
||||||
|
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
|
||||||
|
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Sad Path", () => {
|
describe("Sad Path", () => {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const selectSurvey = {
|
|||||||
isVerifyEmailEnabled: true,
|
isVerifyEmailEnabled: true,
|
||||||
isSingleResponsePerEmailEnabled: true,
|
isSingleResponsePerEmailEnabled: true,
|
||||||
isBackButtonHidden: true,
|
isBackButtonHidden: true,
|
||||||
|
isCaptureIpEnabled: true,
|
||||||
redirectUrl: true,
|
redirectUrl: true,
|
||||||
projectOverwrites: true,
|
projectOverwrites: true,
|
||||||
styling: true,
|
styling: true,
|
||||||
@@ -65,6 +66,8 @@ export const selectSurvey = {
|
|||||||
showLanguageSwitch: true,
|
showLanguageSwitch: true,
|
||||||
recaptcha: true,
|
recaptcha: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
customHeadScripts: true,
|
||||||
|
customHeadScriptsMode: true,
|
||||||
languages: {
|
languages: {
|
||||||
select: {
|
select: {
|
||||||
default: true,
|
default: true,
|
||||||
@@ -326,7 +329,7 @@ export const updateSurveyInternal = async (
|
|||||||
? currentSurvey.languages.map((l) => l.language.id)
|
? currentSurvey.languages.map((l) => l.language.id)
|
||||||
: [];
|
: [];
|
||||||
const updatedLanguageIds =
|
const updatedLanguageIds =
|
||||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||||
const enabledLanguageIds = languages.map((language) => {
|
const enabledLanguageIds = languages.map((language) => {
|
||||||
if (language.enabled) return language.language.id;
|
if (language.enabled) return language.language.id;
|
||||||
});
|
});
|
||||||
@@ -563,6 +566,7 @@ export const updateSurveyInternal = async (
|
|||||||
...prismaSurvey, // Properties from prismaSurvey
|
...prismaSurvey, // Properties from prismaSurvey
|
||||||
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
|
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
|
||||||
segment: surveySegment,
|
segment: surveySegment,
|
||||||
|
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
return modifiedSurvey;
|
return modifiedSurvey;
|
||||||
@@ -783,6 +787,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
|||||||
const modifiedSurvey: TSurvey = {
|
const modifiedSurvey: TSurvey = {
|
||||||
...prismaSurvey, // Properties from prismaSurvey
|
...prismaSurvey, // Properties from prismaSurvey
|
||||||
segment: surveySegment,
|
segment: surveySegment,
|
||||||
|
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
return modifiedSurvey;
|
return modifiedSurvey;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
|
|||||||
...surveyPrisma,
|
...surveyPrisma,
|
||||||
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
|
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
|
||||||
segment,
|
segment,
|
||||||
|
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
|
||||||
} as T;
|
} as T;
|
||||||
|
|
||||||
return transformedSurvey;
|
return transformedSurvey;
|
||||||
|
|||||||
@@ -90,11 +90,10 @@ describe("locale", () => {
|
|||||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||||
|
|
||||||
// Verify Swedish has a language entry with proper labels
|
// Verify Swedish has a language entry with proper label
|
||||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||||
expect(swedishLanguage).toBeDefined();
|
expect(swedishLanguage).toBeDefined();
|
||||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
|
||||||
|
|
||||||
// Verify the locale can be matched from Accept-Language header
|
// Verify the locale can be matched from Accept-Language header
|
||||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
|
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
|
||||||
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
|
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
|
||||||
"privacy_policy": "Datenschutzerklärung",
|
"privacy_policy": "Datenschutzerklärung",
|
||||||
|
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
|
||||||
|
"product_updates_title": "Produkt-Updates",
|
||||||
|
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
|
||||||
|
"security_updates_title": "Sicherheits-Updates",
|
||||||
"terms_of_service": "Nutzungsbedingungen",
|
"terms_of_service": "Nutzungsbedingungen",
|
||||||
"title": "Erstelle dein Formbricks-Konto"
|
"title": "Erstelle dein Formbricks-Konto"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Dokumentation",
|
"docs": "Dokumentation",
|
||||||
"documentation": "Dokumentation",
|
"documentation": "Dokumentation",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
|
"done": "Fertig",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"draft": "Entwurf",
|
"draft": "Entwurf",
|
||||||
"duplicate": "Duplikat",
|
"duplicate": "Duplikat",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Webhook hinzufügen",
|
"add_webhook": "Webhook hinzufügen",
|
||||||
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
||||||
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
||||||
|
"copy_secret_now": "Signierungsschlüssel kopieren",
|
||||||
"created_by_third_party": "Erstellt von einer dritten Partei",
|
"created_by_third_party": "Erstellt von einer dritten Partei",
|
||||||
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
||||||
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||||
|
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||||
"response_created": "Antwort erstellt",
|
"response_created": "Antwort erstellt",
|
||||||
"response_finished": "Antwort abgeschlossen",
|
"response_finished": "Antwort abgeschlossen",
|
||||||
"response_updated": "Antwort aktualisiert",
|
"response_updated": "Antwort aktualisiert",
|
||||||
|
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
|
||||||
|
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
|
||||||
|
"signing_secret": "Signierungsschlüssel",
|
||||||
"source": "Quelle",
|
"source": "Quelle",
|
||||||
"test_endpoint": "Test-Endpunkt",
|
"test_endpoint": "Test-Endpunkt",
|
||||||
"triggers": "Auslöser",
|
"triggers": "Auslöser",
|
||||||
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
||||||
|
"webhook_created": "Webhook erstellt",
|
||||||
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
||||||
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
||||||
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Logo entfernen",
|
"remove_logo": "Logo entfernen",
|
||||||
"replace_logo": "Logo ersetzen",
|
"replace_logo": "Logo ersetzen",
|
||||||
"resend_invitation_email": "Einladungsemail erneut senden",
|
"resend_invitation_email": "Einladungsemail erneut senden",
|
||||||
|
"security_list_tip": "Haben Sie sich für unsere Sicherheitsliste angemeldet? Bleiben Sie informiert, um Ihre Instanz sicher zu halten!",
|
||||||
|
"security_list_tip_link": "Hier registrieren.",
|
||||||
"share_invite_link": "Einladungslink teilen",
|
"share_invite_link": "Einladungslink teilen",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
|
||||||
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
|
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Zuweisen =",
|
"assign": "Zuweisen =",
|
||||||
"audience": "Publikum",
|
"audience": "Publikum",
|
||||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||||
|
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||||
|
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||||
|
"auto_save_on": "Automatisches Speichern an",
|
||||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
||||||
"calculate": "Berechnen",
|
"calculate": "Berechnen",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
||||||
|
"capture_ip_address": "IP-Adresse erfassen",
|
||||||
|
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
|
||||||
"capture_new_action": "Neue Aktion erfassen",
|
"capture_new_action": "Neue Aktion erfassen",
|
||||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||||
"card_background_color": "Hintergrundfarbe der Karte",
|
"card_background_color": "Hintergrundfarbe der Karte",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Bitte angeben",
|
"please_specify": "Bitte angeben",
|
||||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||||
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
|
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
|
||||||
|
"progress_saved": "Fortschritt gespeichert",
|
||||||
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
|
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
|
||||||
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
|
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
|
||||||
"publish": "Veröffentlichen",
|
"publish": "Veröffentlichen",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||||
"first_name": "Vorname",
|
"first_name": "Vorname",
|
||||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||||
|
"ip_address": "IP-Adresse",
|
||||||
"last_name": "Nachname",
|
"last_name": "Nachname",
|
||||||
"not_completed": "Nicht abgeschlossen ⏳",
|
"not_completed": "Nicht abgeschlossen ⏳",
|
||||||
"os": "Betriebssystem",
|
"os": "Betriebssystem",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
|
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
|
||||||
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
|
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Umfrage-Skripte werden zusätzlich zu den Workspace-Skripten ausgeführt.",
|
||||||
|
"add_to_workspace": "Zu Workspace-Skripten hinzufügen",
|
||||||
|
"description": "Tracking-Skripte und Pixel zu dieser Umfrage hinzufügen",
|
||||||
|
"nav_title": "Benutzerdefiniertes HTML",
|
||||||
|
"no_workspace_scripts": "Keine Workspace-Skripte konfiguriert. Sie können diese in Workspace-Einstellungen → Allgemein hinzufügen.",
|
||||||
|
"placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Nur Umfrage-Skripte werden ausgeführt. Workspace-Skripte werden ignoriert. Leer lassen, um keine Skripte zu laden.",
|
||||||
|
"replace_workspace": "Workspace-Skripte ersetzen",
|
||||||
|
"saved_successfully": "Benutzerdefinierte Skripte erfolgreich gespeichert",
|
||||||
|
"script_mode": "Skript-Modus",
|
||||||
|
"security_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||||
|
"survey_scripts_description": "Benutzerdefiniertes HTML hinzufügen, das in den <head> dieser Umfrageseite eingefügt wird.",
|
||||||
|
"survey_scripts_label": "Umfragespezifische Skripte",
|
||||||
|
"workspace_scripts_label": "Workspace-Skripte (vererbt)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Umfrage bearbeiten",
|
"alert_button": "Umfrage bearbeiten",
|
||||||
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab ‚Einstellungen‘ im Umfrage-Editor ändern.",
|
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab ‚Einstellungen‘ im Umfrage-Editor ändern.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Dies ist Ihr einziges Projekt, es kann nicht gelöscht werden. Erstellen Sie zuerst ein neues Projekt.",
|
"cannot_delete_only_workspace": "Dies ist Ihr einziges Projekt, es kann nicht gelöscht werden. Erstellen Sie zuerst ein neues Projekt.",
|
||||||
|
"custom_scripts": "Benutzerdefinierte Skripte",
|
||||||
|
"custom_scripts_card_description": "Tracking-Skripte und Pixel zu allen Link-Umfragen in diesem Workspace hinzufügen.",
|
||||||
|
"custom_scripts_description": "Skripte werden in den <head> aller Link-Umfrageseiten eingefügt.",
|
||||||
|
"custom_scripts_label": "HTML-Skripte",
|
||||||
|
"custom_scripts_placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Benutzerdefinierte Skripte erfolgreich aktualisiert",
|
||||||
|
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||||
"delete_workspace": "Projekt löschen",
|
"delete_workspace": "Projekt löschen",
|
||||||
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
|
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
|
||||||
"please_verify_captcha": "Please verify reCAPTCHA",
|
"please_verify_captcha": "Please verify reCAPTCHA",
|
||||||
"privacy_policy": "Privacy Policy",
|
"privacy_policy": "Privacy Policy",
|
||||||
|
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
|
||||||
|
"product_updates_title": "Product updates",
|
||||||
|
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
|
||||||
|
"security_updates_title": "Security updates",
|
||||||
"terms_of_service": "Terms of Service",
|
"terms_of_service": "Terms of Service",
|
||||||
"title": "Create your Formbricks account"
|
"title": "Create your Formbricks account"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentation",
|
"docs": "Documentation",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
|
"done": "Done",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Add Webhook",
|
"add_webhook": "Add Webhook",
|
||||||
"add_webhook_description": "Send survey response data to a custom endpoint",
|
"add_webhook_description": "Send survey response data to a custom endpoint",
|
||||||
"all_current_and_new_surveys": "All current and new surveys",
|
"all_current_and_new_surveys": "All current and new surveys",
|
||||||
|
"copy_secret_now": "Copy your signing secret",
|
||||||
"created_by_third_party": "Created by a Third Party",
|
"created_by_third_party": "Created by a Third Party",
|
||||||
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
|
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
|
||||||
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
|
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
|
||||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||||
|
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||||
"please_check_console": "Please check the console for more details",
|
"please_check_console": "Please check the console for more details",
|
||||||
"please_enter_a_url": "Please enter a URL",
|
"please_enter_a_url": "Please enter a URL",
|
||||||
"response_created": "Response Created",
|
"response_created": "Response Created",
|
||||||
"response_finished": "Response Finished",
|
"response_finished": "Response Finished",
|
||||||
"response_updated": "Response Updated",
|
"response_updated": "Response Updated",
|
||||||
|
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
|
||||||
|
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
|
||||||
|
"signing_secret": "Signing Secret",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"test_endpoint": "Test Endpoint",
|
"test_endpoint": "Test Endpoint",
|
||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
"webhook_added_successfully": "Webhook added successfully",
|
"webhook_added_successfully": "Webhook added successfully",
|
||||||
|
"webhook_created": "Webhook Created",
|
||||||
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
|
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
|
||||||
"webhook_deleted_successfully": "Webhook deleted successfully",
|
"webhook_deleted_successfully": "Webhook deleted successfully",
|
||||||
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
|
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Remove logo",
|
"remove_logo": "Remove logo",
|
||||||
"replace_logo": "Replace logo",
|
"replace_logo": "Replace logo",
|
||||||
"resend_invitation_email": "Resend Invitation Email",
|
"resend_invitation_email": "Resend Invitation Email",
|
||||||
|
"security_list_tip": "Are you signed up for our Security List? Stay informed to keep your instance secure!",
|
||||||
|
"security_list_tip_link": "Sign up here.",
|
||||||
"share_invite_link": "Share Invite Link",
|
"share_invite_link": "Share Invite Link",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
|
||||||
"test_email_sent_successfully": "Test email sent successfully",
|
"test_email_sent_successfully": "Test email sent successfully",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Assign =",
|
"assign": "Assign =",
|
||||||
"audience": "Audience",
|
"audience": "Audience",
|
||||||
"auto_close_on_inactivity": "Auto close on inactivity",
|
"auto_close_on_inactivity": "Auto close on inactivity",
|
||||||
|
"auto_save_disabled": "Auto-save disabled",
|
||||||
|
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||||
|
"auto_save_on": "Auto-save on",
|
||||||
"automatically_close_survey_after": "Automatically close survey after",
|
"automatically_close_survey_after": "Automatically close survey after",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com username or username/event",
|
"cal_username": "Cal.com username or username/event",
|
||||||
"calculate": "Calculate",
|
"calculate": "Calculate",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
|
||||||
|
"capture_ip_address": "Capture IP address",
|
||||||
|
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
|
||||||
"capture_new_action": "Capture new action",
|
"capture_new_action": "Capture new action",
|
||||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||||
"card_background_color": "Card background color",
|
"card_background_color": "Card background color",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Please specify",
|
"please_specify": "Please specify",
|
||||||
"prevent_double_submission": "Prevent double submission",
|
"prevent_double_submission": "Prevent double submission",
|
||||||
"prevent_double_submission_description": "Only allow 1 response per email address",
|
"prevent_double_submission_description": "Only allow 1 response per email address",
|
||||||
|
"progress_saved": "Progress saved",
|
||||||
"protect_survey_with_pin": "Protect survey with a PIN",
|
"protect_survey_with_pin": "Protect survey with a PIN",
|
||||||
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
|
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
|
||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "An error occurred while downloading responses",
|
"error_downloading_responses": "An error occurred while downloading responses",
|
||||||
"first_name": "First Name",
|
"first_name": "First Name",
|
||||||
"how_to_identify_users": "How to identify users",
|
"how_to_identify_users": "How to identify users",
|
||||||
|
"ip_address": "IP Address",
|
||||||
"last_name": "Last Name",
|
"last_name": "Last Name",
|
||||||
"not_completed": "Not Completed ⏳",
|
"not_completed": "Not Completed ⏳",
|
||||||
"os": "OS",
|
"os": "OS",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
|
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
|
||||||
"url_encryption_label": "URL encryption of single-use ID"
|
"url_encryption_label": "URL encryption of single-use ID"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Survey scripts will run in addition to workspace-level scripts.",
|
||||||
|
"add_to_workspace": "Add to Workspace scripts",
|
||||||
|
"description": "Add tracking scripts and pixels to this survey",
|
||||||
|
"nav_title": "Custom HTML",
|
||||||
|
"no_workspace_scripts": "No workspace-level scripts configured. You can add them in Workspace Settings → General.",
|
||||||
|
"placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Only survey scripts will run. Workspace scripts will be ignored. Keep empty to not load any scripts.",
|
||||||
|
"replace_workspace": "Replace Workspace scripts",
|
||||||
|
"saved_successfully": "Custom scripts saved successfully",
|
||||||
|
"script_mode": "Script Mode",
|
||||||
|
"security_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||||
|
"survey_scripts_description": "Add custom HTML to inject into the <head> of this survey page.",
|
||||||
|
"survey_scripts_label": "Survey-specific scripts",
|
||||||
|
"workspace_scripts_label": "Workspace scripts (inherited)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Edit survey",
|
"alert_button": "Edit survey",
|
||||||
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
|
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
|
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
|
||||||
|
"custom_scripts": "Custom Scripts",
|
||||||
|
"custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.",
|
||||||
|
"custom_scripts_description": "Scripts will be injected into the <head> of all link survey pages.",
|
||||||
|
"custom_scripts_label": "HTML Scripts",
|
||||||
|
"custom_scripts_placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Custom scripts updated successfully",
|
||||||
|
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||||
"delete_workspace": "Delete Workspace",
|
"delete_workspace": "Delete Workspace",
|
||||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
|
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
|
||||||
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
|
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
|
||||||
"privacy_policy": "Política de privacidad",
|
"privacy_policy": "Política de privacidad",
|
||||||
|
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
|
||||||
|
"product_updates_title": "Actualizaciones del producto",
|
||||||
|
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
|
||||||
|
"security_updates_title": "Actualizaciones de seguridad",
|
||||||
"terms_of_service": "Términos de servicio",
|
"terms_of_service": "Términos de servicio",
|
||||||
"title": "Crea tu cuenta de Formbricks"
|
"title": "Crea tu cuenta de Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentación",
|
"docs": "Documentación",
|
||||||
"documentation": "Documentación",
|
"documentation": "Documentación",
|
||||||
"domain": "Dominio",
|
"domain": "Dominio",
|
||||||
|
"done": "Hecho",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"draft": "Borrador",
|
"draft": "Borrador",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Añadir webhook",
|
"add_webhook": "Añadir webhook",
|
||||||
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
||||||
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
||||||
|
"copy_secret_now": "Copia tu secreto de firma",
|
||||||
"created_by_third_party": "Creado por un tercero",
|
"created_by_third_party": "Creado por un tercero",
|
||||||
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
||||||
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
||||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||||
|
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||||
"please_enter_a_url": "Por favor, introduce una URL",
|
"please_enter_a_url": "Por favor, introduce una URL",
|
||||||
"response_created": "Respuesta creada",
|
"response_created": "Respuesta creada",
|
||||||
"response_finished": "Respuesta finalizada",
|
"response_finished": "Respuesta finalizada",
|
||||||
"response_updated": "Respuesta actualizada",
|
"response_updated": "Respuesta actualizada",
|
||||||
|
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
|
||||||
|
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
|
||||||
|
"signing_secret": "Secreto de firma",
|
||||||
"source": "Origen",
|
"source": "Origen",
|
||||||
"test_endpoint": "Probar endpoint",
|
"test_endpoint": "Probar endpoint",
|
||||||
"triggers": "Disparadores",
|
"triggers": "Disparadores",
|
||||||
"webhook_added_successfully": "Webhook añadido correctamente",
|
"webhook_added_successfully": "Webhook añadido correctamente",
|
||||||
|
"webhook_created": "Webhook creado",
|
||||||
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
||||||
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
||||||
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Eliminar logotipo",
|
"remove_logo": "Eliminar logotipo",
|
||||||
"replace_logo": "Reemplazar logotipo",
|
"replace_logo": "Reemplazar logotipo",
|
||||||
"resend_invitation_email": "Reenviar correo electrónico de invitación",
|
"resend_invitation_email": "Reenviar correo electrónico de invitación",
|
||||||
|
"security_list_tip": "¿Estás suscrito a nuestra lista de seguridad? ¡Mantente informado para mantener tu instancia segura!",
|
||||||
|
"security_list_tip_link": "Regístrate aquí.",
|
||||||
"share_invite_link": "Compartir enlace de invitación",
|
"share_invite_link": "Compartir enlace de invitación",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
|
||||||
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
|
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Asignar =",
|
"assign": "Asignar =",
|
||||||
"audience": "Audiencia",
|
"audience": "Audiencia",
|
||||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||||
|
"auto_save_disabled": "Guardado automático desactivado",
|
||||||
|
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||||
|
"auto_save_on": "Guardado automático activado",
|
||||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
||||||
"calculate": "Calcular",
|
"calculate": "Calcular",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
||||||
|
"capture_ip_address": "Capturar dirección IP",
|
||||||
|
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
|
||||||
"capture_new_action": "Capturar nueva acción",
|
"capture_new_action": "Capturar nueva acción",
|
||||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||||
"card_background_color": "Color de fondo de la tarjeta",
|
"card_background_color": "Color de fondo de la tarjeta",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Por favor, especifica",
|
"please_specify": "Por favor, especifica",
|
||||||
"prevent_double_submission": "Evitar envío duplicado",
|
"prevent_double_submission": "Evitar envío duplicado",
|
||||||
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
|
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
|
||||||
|
"progress_saved": "Progreso guardado",
|
||||||
"protect_survey_with_pin": "Proteger encuesta con un PIN",
|
"protect_survey_with_pin": "Proteger encuesta con un PIN",
|
||||||
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
|
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
|
||||||
"publish": "Publicar",
|
"publish": "Publicar",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
||||||
"first_name": "Nombre",
|
"first_name": "Nombre",
|
||||||
"how_to_identify_users": "Cómo identificar a los usuarios",
|
"how_to_identify_users": "Cómo identificar a los usuarios",
|
||||||
|
"ip_address": "Dirección IP",
|
||||||
"last_name": "Apellido",
|
"last_name": "Apellido",
|
||||||
"not_completed": "No completado ⏳",
|
"not_completed": "No completado ⏳",
|
||||||
"os": "Sistema operativo",
|
"os": "Sistema operativo",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Desactiva solo si necesitas establecer un ID de uso único personalizado.",
|
"url_encryption_description": "Desactiva solo si necesitas establecer un ID de uso único personalizado.",
|
||||||
"url_encryption_label": "Cifrado URL del ID de uso único"
|
"url_encryption_label": "Cifrado URL del ID de uso único"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Los scripts de la encuesta se ejecutarán además de los scripts a nivel de espacio de trabajo.",
|
||||||
|
"add_to_workspace": "Añadir a los scripts del espacio de trabajo",
|
||||||
|
"description": "Añade scripts de seguimiento y píxeles a esta encuesta",
|
||||||
|
"nav_title": "HTML personalizado",
|
||||||
|
"no_workspace_scripts": "No hay scripts configurados a nivel de espacio de trabajo. Puedes añadirlos en Configuración del espacio de trabajo → General.",
|
||||||
|
"placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Solo se ejecutarán los scripts de la encuesta. Los scripts del espacio de trabajo serán ignorados. Déjalo vacío para no cargar ningún script.",
|
||||||
|
"replace_workspace": "Reemplazar scripts del espacio de trabajo",
|
||||||
|
"saved_successfully": "Scripts personalizados guardados correctamente",
|
||||||
|
"script_mode": "Modo de script",
|
||||||
|
"security_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||||
|
"survey_scripts_description": "Añade HTML personalizado para inyectar en el <head> de esta página de encuesta.",
|
||||||
|
"survey_scripts_label": "Scripts específicos de la encuesta",
|
||||||
|
"workspace_scripts_label": "Scripts del espacio de trabajo (heredados)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Editar encuesta",
|
"alert_button": "Editar encuesta",
|
||||||
"alert_description": "Esta encuesta está actualmente configurada como una encuesta de enlace, que no admite ventanas emergentes dinámicas. Puedes cambiar esto en la pestaña de ajustes del editor de encuestas.",
|
"alert_description": "Esta encuesta está actualmente configurada como una encuesta de enlace, que no admite ventanas emergentes dinámicas. Puedes cambiar esto en la pestaña de ajustes del editor de encuestas.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
|
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
|
||||||
|
"custom_scripts": "Scripts personalizados",
|
||||||
|
"custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.",
|
||||||
|
"custom_scripts_description": "Los scripts se inyectarán en el <head> de todas las páginas de encuestas con enlace.",
|
||||||
|
"custom_scripts_label": "Scripts HTML",
|
||||||
|
"custom_scripts_placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Scripts personalizados actualizados correctamente",
|
||||||
|
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||||
"delete_workspace": "Eliminar proyecto",
|
"delete_workspace": "Eliminar proyecto",
|
||||||
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
|
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
|
||||||
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
|
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
|
||||||
"privacy_policy": "Politique de confidentialité",
|
"privacy_policy": "Politique de confidentialité",
|
||||||
|
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
|
||||||
|
"product_updates_title": "Mises à jour du produit",
|
||||||
|
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
|
||||||
|
"security_updates_title": "Mises à jour de sécurité",
|
||||||
"terms_of_service": "Conditions d'utilisation",
|
"terms_of_service": "Conditions d'utilisation",
|
||||||
"title": "Créez votre compte Formbricks"
|
"title": "Créez votre compte Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentation",
|
"docs": "Documentation",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"domain": "Domaine",
|
"domain": "Domaine",
|
||||||
|
"done": "Terminé",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"draft": "Brouillon",
|
"draft": "Brouillon",
|
||||||
"duplicate": "Dupliquer",
|
"duplicate": "Dupliquer",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Ajouter un Webhook",
|
"add_webhook": "Ajouter un Webhook",
|
||||||
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
||||||
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
||||||
|
"copy_secret_now": "Copiez votre secret de signature",
|
||||||
"created_by_third_party": "Créé par un tiers",
|
"created_by_third_party": "Créé par un tiers",
|
||||||
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
||||||
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||||
|
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||||
"response_created": "Réponse créée",
|
"response_created": "Réponse créée",
|
||||||
"response_finished": "Réponse terminée",
|
"response_finished": "Réponse terminée",
|
||||||
"response_updated": "Réponse mise à jour",
|
"response_updated": "Réponse mise à jour",
|
||||||
|
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
|
||||||
|
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
|
||||||
|
"signing_secret": "Secret de signature",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"test_endpoint": "Point de test",
|
"test_endpoint": "Point de test",
|
||||||
"triggers": "Déclencheurs",
|
"triggers": "Déclencheurs",
|
||||||
"webhook_added_successfully": "Webhook ajouté avec succès",
|
"webhook_added_successfully": "Webhook ajouté avec succès",
|
||||||
|
"webhook_created": "Webhook créé",
|
||||||
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
||||||
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
||||||
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Supprimer le logo",
|
"remove_logo": "Supprimer le logo",
|
||||||
"replace_logo": "Remplacer le logo",
|
"replace_logo": "Remplacer le logo",
|
||||||
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
|
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
|
||||||
|
"security_list_tip": "Êtes-vous inscrit à notre liste de sécurité ? Restez informé pour maintenir votre instance sécurisée !",
|
||||||
|
"security_list_tip_link": "Inscrivez-vous ici.",
|
||||||
"share_invite_link": "Partager le lien d'invitation",
|
"share_invite_link": "Partager le lien d'invitation",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
|
||||||
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
|
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Attribuer =",
|
"assign": "Attribuer =",
|
||||||
"audience": "Public",
|
"audience": "Public",
|
||||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||||
|
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||||
|
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||||
|
"auto_save_on": "Sauvegarde automatique activée",
|
||||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
||||||
"calculate": "Calculer",
|
"calculate": "Calculer",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
||||||
|
"capture_ip_address": "Capturer l'adresse IP",
|
||||||
|
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
|
||||||
"capture_new_action": "Capturer une nouvelle action",
|
"capture_new_action": "Capturer une nouvelle action",
|
||||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||||
"card_background_color": "Couleur de fond de la carte",
|
"card_background_color": "Couleur de fond de la carte",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Veuillez préciser",
|
"please_specify": "Veuillez préciser",
|
||||||
"prevent_double_submission": "Empêcher la double soumission",
|
"prevent_double_submission": "Empêcher la double soumission",
|
||||||
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
|
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
|
||||||
|
"progress_saved": "Progression enregistrée",
|
||||||
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
|
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
|
||||||
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
||||||
"publish": "Publier",
|
"publish": "Publier",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||||
"first_name": "Prénom",
|
"first_name": "Prénom",
|
||||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||||
|
"ip_address": "Adresse IP",
|
||||||
"last_name": "Nom de famille",
|
"last_name": "Nom de famille",
|
||||||
"not_completed": "Non terminé ⏳",
|
"not_completed": "Non terminé ⏳",
|
||||||
"os": "Système d'exploitation",
|
"os": "Système d'exploitation",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
|
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
|
||||||
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
|
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Les scripts de l'enquête s'exécuteront en plus des scripts au niveau de l'espace de travail.",
|
||||||
|
"add_to_workspace": "Ajouter aux scripts de l'espace de travail",
|
||||||
|
"description": "Ajouter des scripts de suivi et des pixels à cette enquête",
|
||||||
|
"nav_title": "HTML personnalisé",
|
||||||
|
"no_workspace_scripts": "Aucun script au niveau de l'espace de travail configuré. Vous pouvez les ajouter dans Paramètres de l'espace de travail → Général.",
|
||||||
|
"placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Seuls les scripts de l'enquête s'exécuteront. Les scripts de l'espace de travail seront ignorés. Laissez vide pour ne charger aucun script.",
|
||||||
|
"replace_workspace": "Remplacer les scripts de l'espace de travail",
|
||||||
|
"saved_successfully": "Scripts personnalisés enregistrés avec succès",
|
||||||
|
"script_mode": "Mode de script",
|
||||||
|
"security_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||||
|
"survey_scripts_description": "Ajouter du HTML personnalisé à injecter dans le <head> de cette page d'enquête.",
|
||||||
|
"survey_scripts_label": "Scripts spécifiques à l'enquête",
|
||||||
|
"workspace_scripts_label": "Scripts de l'espace de travail (hérités)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Modifier enquête",
|
"alert_button": "Modifier enquête",
|
||||||
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
|
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
|
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
|
||||||
|
"custom_scripts": "Scripts personnalisés",
|
||||||
|
"custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.",
|
||||||
|
"custom_scripts_description": "Les scripts seront injectés dans le <head> de toutes les pages d'enquête par lien.",
|
||||||
|
"custom_scripts_label": "Scripts HTML",
|
||||||
|
"custom_scripts_placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Scripts personnalisés mis à jour avec succès",
|
||||||
|
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||||
"delete_workspace": "Supprimer le projet",
|
"delete_workspace": "Supprimer le projet",
|
||||||
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
|
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
|
||||||
"please_verify_captcha": "reCAPTCHAを認証してください",
|
"please_verify_captcha": "reCAPTCHAを認証してください",
|
||||||
"privacy_policy": "プライバシーポリシー",
|
"privacy_policy": "プライバシーポリシー",
|
||||||
|
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
|
||||||
|
"product_updates_title": "製品アップデート",
|
||||||
|
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
|
||||||
|
"security_updates_title": "セキュリティアップデート",
|
||||||
"terms_of_service": "利用規約",
|
"terms_of_service": "利用規約",
|
||||||
"title": "Formbricksアカウントを作成"
|
"title": "Formbricksアカウントを作成"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "ドキュメント",
|
"docs": "ドキュメント",
|
||||||
"documentation": "ドキュメント",
|
"documentation": "ドキュメント",
|
||||||
"domain": "ドメイン",
|
"domain": "ドメイン",
|
||||||
|
"done": "完了",
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"draft": "下書き",
|
"draft": "下書き",
|
||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Webhook を追加",
|
"add_webhook": "Webhook を追加",
|
||||||
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
||||||
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
||||||
|
"copy_secret_now": "署名シークレットをコピー",
|
||||||
"created_by_third_party": "サードパーティによって作成",
|
"created_by_third_party": "サードパーティによって作成",
|
||||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||||
|
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||||
"please_check_console": "詳細はコンソールを確認してください",
|
"please_check_console": "詳細はコンソールを確認してください",
|
||||||
"please_enter_a_url": "URL を入力してください",
|
"please_enter_a_url": "URL を入力してください",
|
||||||
"response_created": "回答作成",
|
"response_created": "回答作成",
|
||||||
"response_finished": "回答完了",
|
"response_finished": "回答完了",
|
||||||
"response_updated": "回答更新",
|
"response_updated": "回答更新",
|
||||||
|
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
|
||||||
|
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
|
||||||
|
"signing_secret": "署名シークレット",
|
||||||
"source": "ソース",
|
"source": "ソース",
|
||||||
"test_endpoint": "エンドポイントをテスト",
|
"test_endpoint": "エンドポイントをテスト",
|
||||||
"triggers": "トリガー",
|
"triggers": "トリガー",
|
||||||
"webhook_added_successfully": "Webhook を追加しました",
|
"webhook_added_successfully": "Webhook を追加しました",
|
||||||
|
"webhook_created": "Webhook を作成しました",
|
||||||
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
||||||
"webhook_deleted_successfully": "Webhook を削除しました",
|
"webhook_deleted_successfully": "Webhook を削除しました",
|
||||||
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "ロゴを削除",
|
"remove_logo": "ロゴを削除",
|
||||||
"replace_logo": "ロゴを交換",
|
"replace_logo": "ロゴを交換",
|
||||||
"resend_invitation_email": "招待メールを再送信",
|
"resend_invitation_email": "招待メールを再送信",
|
||||||
|
"security_list_tip": "セキュリティリストに登録していますか?インスタンスを安全に保つために最新情報を入手しましょう!",
|
||||||
|
"security_list_tip_link": "こちらからサインアップしてください。",
|
||||||
"share_invite_link": "招待リンクを共有",
|
"share_invite_link": "招待リンクを共有",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
|
||||||
"test_email_sent_successfully": "テストメールを正常に送信しました",
|
"test_email_sent_successfully": "テストメールを正常に送信しました",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "割り当て =",
|
"assign": "割り当て =",
|
||||||
"audience": "オーディエンス",
|
"audience": "オーディエンス",
|
||||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||||
|
"auto_save_disabled": "自動保存が無効",
|
||||||
|
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||||
|
"auto_save_on": "自動保存オン",
|
||||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
||||||
"calculate": "計算",
|
"calculate": "計算",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
||||||
|
"capture_ip_address": "IPアドレスを記録",
|
||||||
|
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
|
||||||
"capture_new_action": "新しいアクションをキャプチャ",
|
"capture_new_action": "新しいアクションをキャプチャ",
|
||||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||||
"card_background_color": "カードの背景色",
|
"card_background_color": "カードの背景色",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "具体的に指定してください",
|
"please_specify": "具体的に指定してください",
|
||||||
"prevent_double_submission": "二重送信を防ぐ",
|
"prevent_double_submission": "二重送信を防ぐ",
|
||||||
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
|
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
|
||||||
|
"progress_saved": "進捗を保存しました",
|
||||||
"protect_survey_with_pin": "PINでフォームを保護",
|
"protect_survey_with_pin": "PINでフォームを保護",
|
||||||
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
|
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
|
||||||
"publish": "公開",
|
"publish": "公開",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||||
"first_name": "名",
|
"first_name": "名",
|
||||||
"how_to_identify_users": "ユーザーを識別する方法",
|
"how_to_identify_users": "ユーザーを識別する方法",
|
||||||
|
"ip_address": "IPアドレス",
|
||||||
"last_name": "姓",
|
"last_name": "姓",
|
||||||
"not_completed": "未完了 ⏳",
|
"not_completed": "未完了 ⏳",
|
||||||
"os": "OS",
|
"os": "OS",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "カスタムの単一使用IDを設定する必要がある場合にのみ無効にしてください。",
|
"url_encryption_description": "カスタムの単一使用IDを設定する必要がある場合にのみ無効にしてください。",
|
||||||
"url_encryption_label": "単一使用IDのURL暗号化"
|
"url_encryption_label": "単一使用IDのURL暗号化"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "アンケートスクリプトは、ワークスペースレベルのスクリプトに加えて実行されます。",
|
||||||
|
"add_to_workspace": "ワークスペーススクリプトに追加",
|
||||||
|
"description": "このアンケートにトラッキングスクリプトとピクセルを追加",
|
||||||
|
"nav_title": "カスタムHTML",
|
||||||
|
"no_workspace_scripts": "ワークスペースレベルのスクリプトが設定されていません。ワークスペース設定→一般から追加できます。",
|
||||||
|
"placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
|
||||||
|
"replace_mode_description": "アンケートスクリプトのみが実行されます。ワークスペーススクリプトは無視されます。スクリプトを読み込まない場合は空のままにしてください。",
|
||||||
|
"replace_workspace": "ワークスペーススクリプトを置き換え",
|
||||||
|
"saved_successfully": "カスタムスクリプトを正常に保存しました",
|
||||||
|
"script_mode": "スクリプトモード",
|
||||||
|
"security_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||||
|
"survey_scripts_description": "このアンケートページの<head>に挿入するカスタムHTMLを追加します。",
|
||||||
|
"survey_scripts_label": "アンケート固有のスクリプト",
|
||||||
|
"workspace_scripts_label": "ワークスペーススクリプト(継承)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "フォームを編集",
|
"alert_button": "フォームを編集",
|
||||||
"alert_description": "このフォームは現在、動的なポップアップをサポートしていないリンクフォームとして設定されています。フォームエディターの設定タブでこれを変更できます。",
|
"alert_description": "このフォームは現在、動的なポップアップをサポートしていないリンクフォームとして設定されています。フォームエディターの設定タブでこれを変更できます。",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。",
|
"cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。",
|
||||||
|
"custom_scripts": "カスタムスクリプト",
|
||||||
|
"custom_scripts_card_description": "このワークスペース内のすべてのリンクアンケートにトラッキングスクリプトとピクセルを追加します。",
|
||||||
|
"custom_scripts_description": "すべてのリンクアンケートページの<head>にスクリプトが挿入されます。",
|
||||||
|
"custom_scripts_label": "HTMLスクリプト",
|
||||||
|
"custom_scripts_placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "カスタムスクリプトを正常に更新しました",
|
||||||
|
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||||
"delete_workspace": "ワークスペースを削除",
|
"delete_workspace": "ワークスペースを削除",
|
||||||
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
|
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
|
||||||
"please_verify_captcha": "Controleer reCAPTCHA",
|
"please_verify_captcha": "Controleer reCAPTCHA",
|
||||||
"privacy_policy": "Privacybeleid",
|
"privacy_policy": "Privacybeleid",
|
||||||
|
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
|
||||||
|
"product_updates_title": "Product-updates",
|
||||||
|
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
|
||||||
|
"security_updates_title": "Beveiligingsupdates",
|
||||||
"terms_of_service": "Servicevoorwaarden",
|
"terms_of_service": "Servicevoorwaarden",
|
||||||
"title": "Maak uw Formbricks-account aan"
|
"title": "Maak uw Formbricks-account aan"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentatie",
|
"docs": "Documentatie",
|
||||||
"documentation": "Documentatie",
|
"documentation": "Documentatie",
|
||||||
"domain": "Domein",
|
"domain": "Domein",
|
||||||
|
"done": "Klaar",
|
||||||
"download": "Downloaden",
|
"download": "Downloaden",
|
||||||
"draft": "Voorlopige versie",
|
"draft": "Voorlopige versie",
|
||||||
"duplicate": "Duplicaat",
|
"duplicate": "Duplicaat",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Webhook toevoegen",
|
"add_webhook": "Webhook toevoegen",
|
||||||
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
||||||
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
||||||
|
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
|
||||||
"created_by_third_party": "Gemaakt door een derde partij",
|
"created_by_third_party": "Gemaakt door een derde partij",
|
||||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||||
|
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||||
"please_check_console": "Controleer de console voor meer details",
|
"please_check_console": "Controleer de console voor meer details",
|
||||||
"please_enter_a_url": "Voer een URL in",
|
"please_enter_a_url": "Voer een URL in",
|
||||||
"response_created": "Reactie gemaakt",
|
"response_created": "Reactie gemaakt",
|
||||||
"response_finished": "Reactie voltooid",
|
"response_finished": "Reactie voltooid",
|
||||||
"response_updated": "Reactie bijgewerkt",
|
"response_updated": "Reactie bijgewerkt",
|
||||||
|
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
|
||||||
|
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
|
||||||
|
"signing_secret": "Ondertekeningsgeheim",
|
||||||
"source": "Bron",
|
"source": "Bron",
|
||||||
"test_endpoint": "Eindpunt testen",
|
"test_endpoint": "Eindpunt testen",
|
||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
||||||
|
"webhook_created": "Webhook aangemaakt",
|
||||||
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
||||||
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
||||||
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Logo verwijderen",
|
"remove_logo": "Logo verwijderen",
|
||||||
"replace_logo": "Logo vervangen",
|
"replace_logo": "Logo vervangen",
|
||||||
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
|
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
|
||||||
|
"security_list_tip": "Ben je aangemeld voor onze beveiligingslijst? Blijf op de hoogte om je instantie veilig te houden!",
|
||||||
|
"security_list_tip_link": "Meld je hier aan.",
|
||||||
"share_invite_link": "Deel de uitnodigingslink",
|
"share_invite_link": "Deel de uitnodigingslink",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
|
||||||
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
|
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Toewijzen =",
|
"assign": "Toewijzen =",
|
||||||
"audience": "Publiek",
|
"audience": "Publiek",
|
||||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||||
|
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||||
|
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||||
|
"auto_save_on": "Automatisch opslaan aan",
|
||||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
||||||
"calculate": "Berekenen",
|
"calculate": "Berekenen",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
||||||
|
"capture_ip_address": "IP-adres vastleggen",
|
||||||
|
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
|
||||||
"capture_new_action": "Leg nieuwe actie vast",
|
"capture_new_action": "Leg nieuwe actie vast",
|
||||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||||
"card_background_color": "Achtergrondkleur van de kaart",
|
"card_background_color": "Achtergrondkleur van de kaart",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Gelieve te specificeren",
|
"please_specify": "Gelieve te specificeren",
|
||||||
"prevent_double_submission": "Voorkom dubbele indiening",
|
"prevent_double_submission": "Voorkom dubbele indiening",
|
||||||
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
|
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
|
||||||
|
"progress_saved": "Voortgang opgeslagen",
|
||||||
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
|
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
|
||||||
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
|
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
|
||||||
"publish": "Publiceren",
|
"publish": "Publiceren",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
||||||
"first_name": "Voornaam",
|
"first_name": "Voornaam",
|
||||||
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
||||||
|
"ip_address": "IP-adres",
|
||||||
"last_name": "Achternaam",
|
"last_name": "Achternaam",
|
||||||
"not_completed": "Niet voltooid ⏳",
|
"not_completed": "Niet voltooid ⏳",
|
||||||
"os": "Besturingssysteem",
|
"os": "Besturingssysteem",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Schakel dit alleen uit als u een aangepaste ID voor eenmalig gebruik moet instellen.",
|
"url_encryption_description": "Schakel dit alleen uit als u een aangepaste ID voor eenmalig gebruik moet instellen.",
|
||||||
"url_encryption_label": "URL-codering van ID voor eenmalig gebruik"
|
"url_encryption_label": "URL-codering van ID voor eenmalig gebruik"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Enquêtescripts worden uitgevoerd naast scripts op werkruimteniveau.",
|
||||||
|
"add_to_workspace": "Toevoegen aan werkruimtescripts",
|
||||||
|
"description": "Voeg trackingscripts en pixels toe aan deze enquête",
|
||||||
|
"nav_title": "Aangepaste HTML",
|
||||||
|
"no_workspace_scripts": "Geen scripts op werkruimteniveau geconfigureerd. Je kunt ze toevoegen in Werkruimte-instellingen → Algemeen.",
|
||||||
|
"placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Alleen enquêtescripts worden uitgevoerd. Werkruimtescripts worden genegeerd. Laat leeg om geen scripts te laden.",
|
||||||
|
"replace_workspace": "Werkruimtescripts vervangen",
|
||||||
|
"saved_successfully": "Aangepaste scripts succesvol opgeslagen",
|
||||||
|
"script_mode": "Scriptmodus",
|
||||||
|
"security_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||||
|
"survey_scripts_description": "Voeg aangepaste HTML toe om te injecteren in de <head> van deze enquêtepagina.",
|
||||||
|
"survey_scripts_label": "Enquêtespecifieke scripts",
|
||||||
|
"workspace_scripts_label": "Werkruimtescripts (overgenomen)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Enquête bewerken",
|
"alert_button": "Enquête bewerken",
|
||||||
"alert_description": "Deze enquête is momenteel geconfigureerd als een linkenquête, die geen dynamische pop-ups ondersteunt. U kunt dit wijzigen op het tabblad Instellingen van de enquête-editor.",
|
"alert_description": "Deze enquête is momenteel geconfigureerd als een linkenquête, die geen dynamische pop-ups ondersteunt. U kunt dit wijzigen op het tabblad Instellingen van de enquête-editor.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
|
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
|
||||||
|
"custom_scripts": "Aangepaste scripts",
|
||||||
|
"custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.",
|
||||||
|
"custom_scripts_description": "Scripts worden geïnjecteerd in de <head> van alle linkenquêtepagina's.",
|
||||||
|
"custom_scripts_label": "HTML-scripts",
|
||||||
|
"custom_scripts_placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Aangepaste scripts succesvol bijgewerkt",
|
||||||
|
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||||
"delete_workspace": "Project verwijderen",
|
"delete_workspace": "Project verwijderen",
|
||||||
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
|
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
|
||||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||||
"privacy_policy": "Política de Privacidade",
|
"privacy_policy": "Política de Privacidade",
|
||||||
|
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
|
||||||
|
"product_updates_title": "Atualizações do produto",
|
||||||
|
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
|
||||||
|
"security_updates_title": "Atualizações de segurança",
|
||||||
"terms_of_service": "Termos de Serviço",
|
"terms_of_service": "Termos de Serviço",
|
||||||
"title": "Crie sua conta no Formbricks"
|
"title": "Crie sua conta no Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentação",
|
"docs": "Documentação",
|
||||||
"documentation": "Documentação",
|
"documentation": "Documentação",
|
||||||
"domain": "Domínio",
|
"domain": "Domínio",
|
||||||
|
"done": "Concluído",
|
||||||
"download": "baixar",
|
"download": "baixar",
|
||||||
"draft": "Rascunho",
|
"draft": "Rascunho",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Adicionar Webhook",
|
"add_webhook": "Adicionar Webhook",
|
||||||
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
||||||
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
||||||
|
"copy_secret_now": "Copie seu segredo de assinatura",
|
||||||
"created_by_third_party": "Criado por um Terceiro",
|
"created_by_third_party": "Criado por um Terceiro",
|
||||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||||
|
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||||
"please_enter_a_url": "Por favor, insira uma URL",
|
"please_enter_a_url": "Por favor, insira uma URL",
|
||||||
"response_created": "Resposta Criada",
|
"response_created": "Resposta Criada",
|
||||||
"response_finished": "Resposta Finalizada",
|
"response_finished": "Resposta Finalizada",
|
||||||
"response_updated": "Resposta Atualizada",
|
"response_updated": "Resposta Atualizada",
|
||||||
|
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
|
||||||
|
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
|
||||||
|
"signing_secret": "Segredo de assinatura",
|
||||||
"source": "fonte",
|
"source": "fonte",
|
||||||
"test_endpoint": "Testar Ponto de Extremidade",
|
"test_endpoint": "Testar Ponto de Extremidade",
|
||||||
"triggers": "gatilhos",
|
"triggers": "gatilhos",
|
||||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||||
|
"webhook_created": "Webhook criado",
|
||||||
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
||||||
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
||||||
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Remover logo",
|
"remove_logo": "Remover logo",
|
||||||
"replace_logo": "Substituir logo",
|
"replace_logo": "Substituir logo",
|
||||||
"resend_invitation_email": "Reenviar E-mail de Convite",
|
"resend_invitation_email": "Reenviar E-mail de Convite",
|
||||||
|
"security_list_tip": "Você está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter sua instância segura!",
|
||||||
|
"security_list_tip_link": "Cadastre-se aqui.",
|
||||||
"share_invite_link": "Compartilhar Link de Convite",
|
"share_invite_link": "Compartilhar Link de Convite",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
|
||||||
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
|
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "atribuir =",
|
"assign": "atribuir =",
|
||||||
"audience": "Público",
|
"audience": "Público",
|
||||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||||
|
"auto_save_disabled": "Salvamento automático desativado",
|
||||||
|
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||||
|
"auto_save_on": "Salvamento automático ativado",
|
||||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
||||||
"calculate": "Calcular",
|
"calculate": "Calcular",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
||||||
|
"capture_ip_address": "Capturar endereço IP",
|
||||||
|
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
|
||||||
"capture_new_action": "Capturar nova ação",
|
"capture_new_action": "Capturar nova ação",
|
||||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||||
"card_background_color": "Cor de fundo do cartão",
|
"card_background_color": "Cor de fundo do cartão",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Por favor, especifique",
|
"please_specify": "Por favor, especifique",
|
||||||
"prevent_double_submission": "Evitar envio duplicado",
|
"prevent_double_submission": "Evitar envio duplicado",
|
||||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||||
|
"progress_saved": "Progresso salvo",
|
||||||
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
|
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
|
||||||
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
|
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
|
||||||
"publish": "Publicar",
|
"publish": "Publicar",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
||||||
"first_name": "Primeiro Nome",
|
"first_name": "Primeiro Nome",
|
||||||
"how_to_identify_users": "Como identificar usuários",
|
"how_to_identify_users": "Como identificar usuários",
|
||||||
|
"ip_address": "Endereço IP",
|
||||||
"last_name": "Sobrenome",
|
"last_name": "Sobrenome",
|
||||||
"not_completed": "Não Concluído ⏳",
|
"not_completed": "Não Concluído ⏳",
|
||||||
"os": "sistema operacional",
|
"os": "sistema operacional",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
|
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
|
||||||
"url_encryption_label": "Criptografia de URL de ID de uso único"
|
"url_encryption_label": "Criptografia de URL de ID de uso único"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Os scripts da pesquisa serão executados além dos scripts do nível do workspace.",
|
||||||
|
"add_to_workspace": "Adicionar aos scripts do workspace",
|
||||||
|
"description": "Adicione scripts de rastreamento e pixels a esta pesquisa",
|
||||||
|
"nav_title": "HTML personalizado",
|
||||||
|
"no_workspace_scripts": "Nenhum script de nível de workspace configurado. Você pode adicioná-los em Configurações do Workspace → Geral.",
|
||||||
|
"placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Apenas os scripts da pesquisa serão executados. Os scripts do workspace serão ignorados. Deixe vazio para não carregar nenhum script.",
|
||||||
|
"replace_workspace": "Substituir scripts do workspace",
|
||||||
|
"saved_successfully": "Scripts personalizados salvos com sucesso",
|
||||||
|
"script_mode": "Modo de script",
|
||||||
|
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||||
|
"survey_scripts_description": "Adicione HTML personalizado para injetar no <head> desta página de pesquisa.",
|
||||||
|
"survey_scripts_label": "Scripts específicos da pesquisa",
|
||||||
|
"workspace_scripts_label": "Scripts do workspace (herdados)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Editar pesquisa",
|
"alert_button": "Editar pesquisa",
|
||||||
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
|
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
|
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
|
||||||
|
"custom_scripts": "Scripts personalizados",
|
||||||
|
"custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.",
|
||||||
|
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de pesquisa de link.",
|
||||||
|
"custom_scripts_label": "Scripts HTML",
|
||||||
|
"custom_scripts_placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
|
||||||
|
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||||
"delete_workspace": "Excluir projeto",
|
"delete_workspace": "Excluir projeto",
|
||||||
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
|
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
|
||||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||||
"privacy_policy": "Política de Privacidade",
|
"privacy_policy": "Política de Privacidade",
|
||||||
|
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
|
||||||
|
"product_updates_title": "Atualizações do produto",
|
||||||
|
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
|
||||||
|
"security_updates_title": "Atualizações de segurança",
|
||||||
"terms_of_service": "Termos de Serviço",
|
"terms_of_service": "Termos de Serviço",
|
||||||
"title": "Crie a sua conta Formbricks"
|
"title": "Crie a sua conta Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentação",
|
"docs": "Documentação",
|
||||||
"documentation": "Documentação",
|
"documentation": "Documentação",
|
||||||
"domain": "Domínio",
|
"domain": "Domínio",
|
||||||
|
"done": "Concluído",
|
||||||
"download": "Transferir",
|
"download": "Transferir",
|
||||||
"draft": "Rascunho",
|
"draft": "Rascunho",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Adicionar Webhook",
|
"add_webhook": "Adicionar Webhook",
|
||||||
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
||||||
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
||||||
|
"copy_secret_now": "Copiar o seu segredo de assinatura",
|
||||||
"created_by_third_party": "Criado por um Terceiro",
|
"created_by_third_party": "Criado por um Terceiro",
|
||||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||||
|
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||||
"please_enter_a_url": "Por favor, insira um URL",
|
"please_enter_a_url": "Por favor, insira um URL",
|
||||||
"response_created": "Resposta Criada",
|
"response_created": "Resposta Criada",
|
||||||
"response_finished": "Resposta Concluída",
|
"response_finished": "Resposta Concluída",
|
||||||
"response_updated": "Resposta Atualizada",
|
"response_updated": "Resposta Atualizada",
|
||||||
|
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
|
||||||
|
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
|
||||||
|
"signing_secret": "Segredo de assinatura",
|
||||||
"source": "Fonte",
|
"source": "Fonte",
|
||||||
"test_endpoint": "Testar Endpoint",
|
"test_endpoint": "Testar Endpoint",
|
||||||
"triggers": "Disparadores",
|
"triggers": "Disparadores",
|
||||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||||
|
"webhook_created": "Webhook criado",
|
||||||
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
||||||
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
||||||
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Remover logótipo",
|
"remove_logo": "Remover logótipo",
|
||||||
"replace_logo": "Substituir logotipo",
|
"replace_logo": "Substituir logotipo",
|
||||||
"resend_invitation_email": "Reenviar Email de Convite",
|
"resend_invitation_email": "Reenviar Email de Convite",
|
||||||
|
"security_list_tip": "Está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter a sua instância segura!",
|
||||||
|
"security_list_tip_link": "Inscreva-se aqui.",
|
||||||
"share_invite_link": "Partilhar Link de Convite",
|
"share_invite_link": "Partilhar Link de Convite",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
|
||||||
"test_email_sent_successfully": "Email de teste enviado com sucesso",
|
"test_email_sent_successfully": "Email de teste enviado com sucesso",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Atribuir =",
|
"assign": "Atribuir =",
|
||||||
"audience": "Público",
|
"audience": "Público",
|
||||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||||
|
"auto_save_disabled": "Guardar automático desativado",
|
||||||
|
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||||
|
"auto_save_on": "Guardar automático ativado",
|
||||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
||||||
"calculate": "Calcular",
|
"calculate": "Calcular",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
||||||
|
"capture_ip_address": "Capturar endereço IP",
|
||||||
|
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
|
||||||
"capture_new_action": "Capturar nova ação",
|
"capture_new_action": "Capturar nova ação",
|
||||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||||
"card_background_color": "Cor de fundo do cartão",
|
"card_background_color": "Cor de fundo do cartão",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Por favor, especifique",
|
"please_specify": "Por favor, especifique",
|
||||||
"prevent_double_submission": "Impedir submissão dupla",
|
"prevent_double_submission": "Impedir submissão dupla",
|
||||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||||
|
"progress_saved": "Progresso guardado",
|
||||||
"protect_survey_with_pin": "Proteger inquérito com um PIN",
|
"protect_survey_with_pin": "Proteger inquérito com um PIN",
|
||||||
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
|
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
|
||||||
"publish": "Publicar",
|
"publish": "Publicar",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
||||||
"first_name": "Primeiro Nome",
|
"first_name": "Primeiro Nome",
|
||||||
"how_to_identify_users": "Como identificar utilizadores",
|
"how_to_identify_users": "Como identificar utilizadores",
|
||||||
|
"ip_address": "Endereço IP",
|
||||||
"last_name": "Apelido",
|
"last_name": "Apelido",
|
||||||
"not_completed": "Não Concluído ⏳",
|
"not_completed": "Não Concluído ⏳",
|
||||||
"os": "SO",
|
"os": "SO",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
|
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
|
||||||
"url_encryption_label": "Encriptação do URL de ID de uso único"
|
"url_encryption_label": "Encriptação do URL de ID de uso único"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Os scripts do inquérito serão executados para além dos scripts ao nível da área de trabalho.",
|
||||||
|
"add_to_workspace": "Adicionar aos scripts da área de trabalho",
|
||||||
|
"description": "Adicionar scripts de rastreamento e pixels a este inquérito",
|
||||||
|
"nav_title": "HTML personalizado",
|
||||||
|
"no_workspace_scripts": "Nenhum script ao nível da área de trabalho configurado. Pode adicioná-los em Definições da Área de Trabalho → Geral.",
|
||||||
|
"placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Apenas os scripts do inquérito serão executados. Os scripts da área de trabalho serão ignorados. Deixe vazio para não carregar nenhum script.",
|
||||||
|
"replace_workspace": "Substituir scripts da área de trabalho",
|
||||||
|
"saved_successfully": "Scripts personalizados guardados com sucesso",
|
||||||
|
"script_mode": "Modo de script",
|
||||||
|
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||||
|
"survey_scripts_description": "Adicionar HTML personalizado para injetar no <head> desta página de inquérito.",
|
||||||
|
"survey_scripts_label": "Scripts específicos do inquérito",
|
||||||
|
"workspace_scripts_label": "Scripts da área de trabalho (herdados)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Editar inquérito",
|
"alert_button": "Editar inquérito",
|
||||||
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
|
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
|
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
|
||||||
|
"custom_scripts": "Scripts personalizados",
|
||||||
|
"custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.",
|
||||||
|
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de inquéritos de link.",
|
||||||
|
"custom_scripts_label": "Scripts HTML",
|
||||||
|
"custom_scripts_placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
|
||||||
|
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||||
"delete_workspace": "Eliminar projeto",
|
"delete_workspace": "Eliminar projeto",
|
||||||
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
||||||
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
||||||
"privacy_policy": "Politica de confidențialitate",
|
"privacy_policy": "Politica de confidențialitate",
|
||||||
|
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
|
||||||
|
"product_updates_title": "Actualizări de produs",
|
||||||
|
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
|
||||||
|
"security_updates_title": "Actualizări de securitate",
|
||||||
"terms_of_service": "Termeni de utilizare a serviciului",
|
"terms_of_service": "Termeni de utilizare a serviciului",
|
||||||
"title": "Creați-vă contul Formbricks"
|
"title": "Creați-vă contul Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Documentație",
|
"docs": "Documentație",
|
||||||
"documentation": "Documentație",
|
"documentation": "Documentație",
|
||||||
"domain": "Domeniu",
|
"domain": "Domeniu",
|
||||||
|
"done": "Gata",
|
||||||
"download": "Descărcare",
|
"download": "Descărcare",
|
||||||
"draft": "Schiță",
|
"draft": "Schiță",
|
||||||
"duplicate": "Duplicități",
|
"duplicate": "Duplicități",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Adaugă Webhook",
|
"add_webhook": "Adaugă Webhook",
|
||||||
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
||||||
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
||||||
|
"copy_secret_now": "Copiază secretul de semnare",
|
||||||
"created_by_third_party": "Creat de o Parte Terță",
|
"created_by_third_party": "Creat de o Parte Terță",
|
||||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||||
|
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||||
"response_created": "Răspuns creat",
|
"response_created": "Răspuns creat",
|
||||||
"response_finished": "Răspuns finalizat",
|
"response_finished": "Răspuns finalizat",
|
||||||
"response_updated": "Răspuns actualizat",
|
"response_updated": "Răspuns actualizat",
|
||||||
|
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
|
||||||
|
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
|
||||||
|
"signing_secret": "Secret de semnare",
|
||||||
"source": "Sursă",
|
"source": "Sursă",
|
||||||
"test_endpoint": "Punct final de test",
|
"test_endpoint": "Punct final de test",
|
||||||
"triggers": "Declanșatori",
|
"triggers": "Declanșatori",
|
||||||
"webhook_added_successfully": "Webhook adăugat cu succes",
|
"webhook_added_successfully": "Webhook adăugat cu succes",
|
||||||
|
"webhook_created": "Webhook creat",
|
||||||
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
||||||
"webhook_deleted_successfully": "Webhook șters cu succes",
|
"webhook_deleted_successfully": "Webhook șters cu succes",
|
||||||
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Înlătură siglă",
|
"remove_logo": "Înlătură siglă",
|
||||||
"replace_logo": "Înlocuiește sigla",
|
"replace_logo": "Înlocuiește sigla",
|
||||||
"resend_invitation_email": "Retrimite emailul de invitație",
|
"resend_invitation_email": "Retrimite emailul de invitație",
|
||||||
|
"security_list_tip": "Ești abonat la lista noastră de securitate? Rămâi informat pentru a-ți menține instanța în siguranță!",
|
||||||
|
"security_list_tip_link": "Înscrie-te aici.",
|
||||||
"share_invite_link": "Distribuie link-ul de invitație",
|
"share_invite_link": "Distribuie link-ul de invitație",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
||||||
"test_email_sent_successfully": "Email de test trimis cu succes",
|
"test_email_sent_successfully": "Email de test trimis cu succes",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Atribuire =",
|
"assign": "Atribuire =",
|
||||||
"audience": "Public",
|
"audience": "Public",
|
||||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||||
|
"auto_save_disabled": "Salvare automată dezactivată",
|
||||||
|
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||||
|
"auto_save_on": "Salvare automată activată",
|
||||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
||||||
"calculate": "Calculați",
|
"calculate": "Calculați",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
||||||
|
"capture_ip_address": "Capturare adresă IP",
|
||||||
|
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
|
||||||
"capture_new_action": "Capturați acțiune nouă",
|
"capture_new_action": "Capturați acțiune nouă",
|
||||||
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
||||||
"card_background_color": "Culoarea de fundal a cardului",
|
"card_background_color": "Culoarea de fundal a cardului",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Vă rugăm să specificați",
|
"please_specify": "Vă rugăm să specificați",
|
||||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||||
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
|
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
|
||||||
|
"progress_saved": "Progres salvat",
|
||||||
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
|
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
|
||||||
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
|
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
|
||||||
"publish": "Publică",
|
"publish": "Publică",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
||||||
"first_name": "Prenume",
|
"first_name": "Prenume",
|
||||||
"how_to_identify_users": "Cum să identifici utilizatorii",
|
"how_to_identify_users": "Cum să identifici utilizatorii",
|
||||||
|
"ip_address": "Adresă IP",
|
||||||
"last_name": "Nume de familie",
|
"last_name": "Nume de familie",
|
||||||
"not_completed": "Necompletat ⏳",
|
"not_completed": "Necompletat ⏳",
|
||||||
"os": "SO",
|
"os": "SO",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.",
|
"url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.",
|
||||||
"url_encryption_label": "Criptarea URL pentru ID unic de utilizare"
|
"url_encryption_label": "Criptarea URL pentru ID unic de utilizare"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Scripturile sondajului vor rula în plus față de scripturile la nivel de spațiu de lucru.",
|
||||||
|
"add_to_workspace": "Adaugă la scripturile spațiului de lucru",
|
||||||
|
"description": "Adaugă scripturi de tracking și pixeli acestui sondaj",
|
||||||
|
"nav_title": "HTML personalizat",
|
||||||
|
"no_workspace_scripts": "Nu există scripturi la nivel de spațiu de lucru configurate. Le poți adăuga în Setări spațiu de lucru → General.",
|
||||||
|
"placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Vor rula doar scripturile sondajului. Scripturile spațiului de lucru vor fi ignorate. Lasă gol pentru a nu încărca niciun script.",
|
||||||
|
"replace_workspace": "Înlocuiește scripturile spațiului de lucru",
|
||||||
|
"saved_successfully": "Scripturile personalizate au fost salvate cu succes",
|
||||||
|
"script_mode": "Modul script",
|
||||||
|
"security_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||||
|
"survey_scripts_description": "Adaugă HTML personalizat pentru a fi injectat în <head> pe această pagină de sondaj.",
|
||||||
|
"survey_scripts_label": "Scripturi specifice sondajului",
|
||||||
|
"workspace_scripts_label": "Scripturi spațiu de lucru (moștenite)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Editează chestionar",
|
"alert_button": "Editează chestionar",
|
||||||
"alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.",
|
"alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
|
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
|
||||||
|
"custom_scripts": "Scripturi personalizate",
|
||||||
|
"custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.",
|
||||||
|
"custom_scripts_description": "Scripturile vor fi injectate în <head> pe toate paginile sondajelor cu link.",
|
||||||
|
"custom_scripts_label": "Scripturi HTML",
|
||||||
|
"custom_scripts_placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Scripturile personalizate au fost actualizate cu succes",
|
||||||
|
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||||
"delete_workspace": "Șterge proiectul",
|
"delete_workspace": "Șterge proiectul",
|
||||||
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
|
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
|
||||||
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
|
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
|
||||||
"privacy_policy": "Политика конфиденциальности",
|
"privacy_policy": "Политика конфиденциальности",
|
||||||
|
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
|
||||||
|
"product_updates_title": "Обновления продукта",
|
||||||
|
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
|
||||||
|
"security_updates_title": "Обновления безопасности",
|
||||||
"terms_of_service": "Условия использования",
|
"terms_of_service": "Условия использования",
|
||||||
"title": "Создайте аккаунт Formbricks"
|
"title": "Создайте аккаунт Formbricks"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Документация",
|
"docs": "Документация",
|
||||||
"documentation": "Документация",
|
"documentation": "Документация",
|
||||||
"domain": "Домен",
|
"domain": "Домен",
|
||||||
|
"done": "Готово",
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"draft": "Черновик",
|
"draft": "Черновик",
|
||||||
"duplicate": "Дублировать",
|
"duplicate": "Дублировать",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Добавить webhook",
|
"add_webhook": "Добавить webhook",
|
||||||
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
||||||
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
||||||
|
"copy_secret_now": "Скопируйте ваш секрет подписи",
|
||||||
"created_by_third_party": "Создано сторонней организацией",
|
"created_by_third_party": "Создано сторонней организацией",
|
||||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||||
|
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||||
"response_created": "Ответ создан",
|
"response_created": "Ответ создан",
|
||||||
"response_finished": "Ответ завершён",
|
"response_finished": "Ответ завершён",
|
||||||
"response_updated": "Ответ обновлён",
|
"response_updated": "Ответ обновлён",
|
||||||
|
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
|
||||||
|
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
|
||||||
|
"signing_secret": "Секрет подписи",
|
||||||
"source": "Источник",
|
"source": "Источник",
|
||||||
"test_endpoint": "Тестировать endpoint",
|
"test_endpoint": "Тестировать endpoint",
|
||||||
"triggers": "Триггеры",
|
"triggers": "Триггеры",
|
||||||
"webhook_added_successfully": "Webhook успешно добавлен",
|
"webhook_added_successfully": "Webhook успешно добавлен",
|
||||||
|
"webhook_created": "Webhook создан",
|
||||||
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
||||||
"webhook_deleted_successfully": "Webhook успешно удалён",
|
"webhook_deleted_successfully": "Webhook успешно удалён",
|
||||||
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Удалить логотип",
|
"remove_logo": "Удалить логотип",
|
||||||
"replace_logo": "Заменить логотип",
|
"replace_logo": "Заменить логотип",
|
||||||
"resend_invitation_email": "Отправить приглашение повторно",
|
"resend_invitation_email": "Отправить приглашение повторно",
|
||||||
|
"security_list_tip": "Вы подписаны на нашу рассылку по безопасности? Будьте в курсе, чтобы обезопасить свой экземпляр!",
|
||||||
|
"security_list_tip_link": "Зарегистрируйтесь здесь.",
|
||||||
"share_invite_link": "Поделиться ссылкой-приглашением",
|
"share_invite_link": "Поделиться ссылкой-приглашением",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
|
||||||
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
|
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Назначить =",
|
"assign": "Назначить =",
|
||||||
"audience": "Аудитория",
|
"audience": "Аудитория",
|
||||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||||
|
"auto_save_disabled": "Автосохранение отключено",
|
||||||
|
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||||
|
"auto_save_on": "Автосохранение включено",
|
||||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Имя пользователя Cal.com или username/event",
|
"cal_username": "Имя пользователя Cal.com или username/event",
|
||||||
"calculate": "Вычислить",
|
"calculate": "Вычислить",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
||||||
|
"capture_ip_address": "Сохранять IP-адрес",
|
||||||
|
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
|
||||||
"capture_new_action": "Захватить новое действие",
|
"capture_new_action": "Захватить новое действие",
|
||||||
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
||||||
"card_background_color": "Цвет фона карточки",
|
"card_background_color": "Цвет фона карточки",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Пожалуйста, уточните",
|
"please_specify": "Пожалуйста, уточните",
|
||||||
"prevent_double_submission": "Предотвратить повторную отправку",
|
"prevent_double_submission": "Предотвратить повторную отправку",
|
||||||
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
|
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
|
||||||
|
"progress_saved": "Прогресс сохранён",
|
||||||
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
|
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
|
||||||
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
|
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
|
||||||
"publish": "Опубликовать",
|
"publish": "Опубликовать",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||||
"first_name": "Имя",
|
"first_name": "Имя",
|
||||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||||
|
"ip_address": "IP-адрес",
|
||||||
"last_name": "Фамилия",
|
"last_name": "Фамилия",
|
||||||
"not_completed": "Не завершено ⏳",
|
"not_completed": "Не завершено ⏳",
|
||||||
"os": "ОС",
|
"os": "ОС",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Отключайте только если нужно задать собственный одноразовый ID.",
|
"url_encryption_description": "Отключайте только если нужно задать собственный одноразовый ID.",
|
||||||
"url_encryption_label": "Шифрование URL для одноразового ID"
|
"url_encryption_label": "Шифрование URL для одноразового ID"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Скрипты опроса будут выполняться дополнительно к скриптам на уровне рабочего пространства.",
|
||||||
|
"add_to_workspace": "Добавить к скриптам рабочего пространства",
|
||||||
|
"description": "Добавьте трекинговые скрипты и пиксели в этот опрос",
|
||||||
|
"nav_title": "Пользовательский HTML",
|
||||||
|
"no_workspace_scripts": "Скрипты на уровне рабочего пространства не настроены. Вы можете добавить их в настройках рабочего пространства → Общие.",
|
||||||
|
"placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
|
||||||
|
"replace_mode_description": "Будут выполняться только скрипты опроса. Скрипты рабочего пространства будут проигнорированы. Оставьте пустым, чтобы не загружать скрипты.",
|
||||||
|
"replace_workspace": "Заменить скрипты рабочего пространства",
|
||||||
|
"saved_successfully": "Пользовательские скрипты успешно сохранены",
|
||||||
|
"script_mode": "Режим скриптов",
|
||||||
|
"security_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||||
|
"survey_scripts_description": "Добавьте пользовательский HTML для внедрения в <head> этой страницы опроса.",
|
||||||
|
"survey_scripts_label": "Скрипты, специфичные для опроса",
|
||||||
|
"workspace_scripts_label": "Скрипты рабочего пространства (унаследованные)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Редактировать опрос",
|
"alert_button": "Редактировать опрос",
|
||||||
"alert_description": "Этот опрос сейчас настроен как опрос по ссылке, что не поддерживает динамические pop-up окна. Вы можете изменить это на вкладке настроек редактора опроса.",
|
"alert_description": "Этот опрос сейчас настроен как опрос по ссылке, что не поддерживает динамические pop-up окна. Вы можете изменить это на вкладке настроек редактора опроса.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
|
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
|
||||||
|
"custom_scripts": "Пользовательские скрипты",
|
||||||
|
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
|
||||||
|
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
|
||||||
|
"custom_scripts_label": "HTML-скрипты",
|
||||||
|
"custom_scripts_placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Пользовательские скрипты успешно обновлены",
|
||||||
|
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||||
"delete_workspace": "Удалить рабочий проект",
|
"delete_workspace": "Удалить рабочий проект",
|
||||||
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
|
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
|
||||||
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
|
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
|
||||||
"privacy_policy": "Integritetspolicy",
|
"privacy_policy": "Integritetspolicy",
|
||||||
|
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
|
||||||
|
"product_updates_title": "Produktuppdateringar",
|
||||||
|
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
|
||||||
|
"security_updates_title": "Säkerhetsuppdateringar",
|
||||||
"terms_of_service": "Användarvillkor",
|
"terms_of_service": "Användarvillkor",
|
||||||
"title": "Skapa ditt Formbricks-konto"
|
"title": "Skapa ditt Formbricks-konto"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "Dokumentation",
|
"docs": "Dokumentation",
|
||||||
"documentation": "Dokumentation",
|
"documentation": "Dokumentation",
|
||||||
"domain": "Domän",
|
"domain": "Domän",
|
||||||
|
"done": "Klar",
|
||||||
"download": "Ladda ner",
|
"download": "Ladda ner",
|
||||||
"draft": "Utkast",
|
"draft": "Utkast",
|
||||||
"duplicate": "Duplicera",
|
"duplicate": "Duplicera",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "Lägg till webhook",
|
"add_webhook": "Lägg till webhook",
|
||||||
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
||||||
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
||||||
|
"copy_secret_now": "Kopiera din signeringsnyckel",
|
||||||
"created_by_third_party": "Skapad av tredje part",
|
"created_by_third_party": "Skapad av tredje part",
|
||||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||||
|
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||||
"please_enter_a_url": "Vänligen ange en URL",
|
"please_enter_a_url": "Vänligen ange en URL",
|
||||||
"response_created": "Svar skapat",
|
"response_created": "Svar skapat",
|
||||||
"response_finished": "Svar slutfört",
|
"response_finished": "Svar slutfört",
|
||||||
"response_updated": "Svar uppdaterat",
|
"response_updated": "Svar uppdaterat",
|
||||||
|
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
|
||||||
|
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
|
||||||
|
"signing_secret": "Signeringsnyckel",
|
||||||
"source": "Källa",
|
"source": "Källa",
|
||||||
"test_endpoint": "Testa endpoint",
|
"test_endpoint": "Testa endpoint",
|
||||||
"triggers": "Utlösare",
|
"triggers": "Utlösare",
|
||||||
"webhook_added_successfully": "Webhook tillagd",
|
"webhook_added_successfully": "Webhook tillagd",
|
||||||
|
"webhook_created": "Webhook skapad",
|
||||||
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
||||||
"webhook_deleted_successfully": "Webhook borttagen",
|
"webhook_deleted_successfully": "Webhook borttagen",
|
||||||
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "Ta bort logotyp",
|
"remove_logo": "Ta bort logotyp",
|
||||||
"replace_logo": "Ersätt logotyp",
|
"replace_logo": "Ersätt logotyp",
|
||||||
"resend_invitation_email": "Skicka inbjudningsmejl igen",
|
"resend_invitation_email": "Skicka inbjudningsmejl igen",
|
||||||
|
"security_list_tip": "Är du med på vår säkerhetslista? Håll dig informerad för att skydda din instans!",
|
||||||
|
"security_list_tip_link": "Registrera dig här.",
|
||||||
"share_invite_link": "Dela inbjudningslänk",
|
"share_invite_link": "Dela inbjudningslänk",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
|
||||||
"test_email_sent_successfully": "Test-e-post skickat",
|
"test_email_sent_successfully": "Test-e-post skickat",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "Tilldela =",
|
"assign": "Tilldela =",
|
||||||
"audience": "Målgrupp",
|
"audience": "Målgrupp",
|
||||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||||
|
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||||
|
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||||
|
"auto_save_on": "Automatisk sparning på",
|
||||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
||||||
"calculate": "Beräkna",
|
"calculate": "Beräkna",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
||||||
|
"capture_ip_address": "Registrera IP-adress",
|
||||||
|
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
|
||||||
"capture_new_action": "Fånga ny åtgärd",
|
"capture_new_action": "Fånga ny åtgärd",
|
||||||
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
||||||
"card_background_color": "Kortets bakgrundsfärg",
|
"card_background_color": "Kortets bakgrundsfärg",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "Vänligen specificera",
|
"please_specify": "Vänligen specificera",
|
||||||
"prevent_double_submission": "Förhindra dubbelinskickning",
|
"prevent_double_submission": "Förhindra dubbelinskickning",
|
||||||
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
|
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
|
||||||
|
"progress_saved": "Framsteg sparade",
|
||||||
"protect_survey_with_pin": "Skydda enkäten med en PIN",
|
"protect_survey_with_pin": "Skydda enkäten med en PIN",
|
||||||
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
|
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
|
||||||
"publish": "Publicera",
|
"publish": "Publicera",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
||||||
"first_name": "Förnamn",
|
"first_name": "Förnamn",
|
||||||
"how_to_identify_users": "Hur man identifierar användare",
|
"how_to_identify_users": "Hur man identifierar användare",
|
||||||
|
"ip_address": "IP-adress",
|
||||||
"last_name": "Efternamn",
|
"last_name": "Efternamn",
|
||||||
"not_completed": "Inte slutförd ⏳",
|
"not_completed": "Inte slutförd ⏳",
|
||||||
"os": "OS",
|
"os": "OS",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "Inaktivera endast om du behöver ange ett anpassat engångs-ID.",
|
"url_encryption_description": "Inaktivera endast om du behöver ange ett anpassat engångs-ID.",
|
||||||
"url_encryption_label": "URL-kryptering av engångs-ID"
|
"url_encryption_label": "URL-kryptering av engångs-ID"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "Undersökningsskript kommer att köras utöver arbetsytans skript.",
|
||||||
|
"add_to_workspace": "Lägg till i arbetsytans skript",
|
||||||
|
"description": "Lägg till spårningsskript och pixlar i denna undersökning",
|
||||||
|
"nav_title": "Anpassad HTML",
|
||||||
|
"no_workspace_scripts": "Inga arbetsytans skript har konfigurerats. Du kan lägga till dem i Arbetsytans inställningar → Allmänt.",
|
||||||
|
"placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"replace_mode_description": "Endast undersökningsskript kommer att köras. Arbetsytans skript ignoreras. Lämna tomt för att inte ladda några skript.",
|
||||||
|
"replace_workspace": "Ersätt arbetsytans skript",
|
||||||
|
"saved_successfully": "Anpassade skript har sparats",
|
||||||
|
"script_mode": "Skriptläge",
|
||||||
|
"security_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||||
|
"survey_scripts_description": "Lägg till anpassad HTML för att injicera i <head> på denna undersökningssida.",
|
||||||
|
"survey_scripts_label": "Undersökningsspecifika skript",
|
||||||
|
"workspace_scripts_label": "Arbetsytans skript (ärvda)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "Redigera enkät",
|
"alert_button": "Redigera enkät",
|
||||||
"alert_description": "Denna enkät är för närvarande konfigurerad som en länkenkät, vilket inte stöder dynamiska popup-fönster. Du kan ändra detta i inställningsfliken i enkätredigeraren.",
|
"alert_description": "Denna enkät är för närvarande konfigurerad som en länkenkät, vilket inte stöder dynamiska popup-fönster. Du kan ändra detta i inställningsfliken i enkätredigeraren.",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.",
|
"cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.",
|
||||||
|
"custom_scripts": "Anpassade skript",
|
||||||
|
"custom_scripts_card_description": "Lägg till spårningsskript och pixlar i alla länkundersökningar i denna arbetsyta.",
|
||||||
|
"custom_scripts_description": "Skript kommer att injiceras i <head> på alla länkundersökningssidor.",
|
||||||
|
"custom_scripts_label": "HTML-skript",
|
||||||
|
"custom_scripts_placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "Anpassade skript har uppdaterats",
|
||||||
|
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||||
"delete_workspace": "Ta bort arbetsyta",
|
"delete_workspace": "Ta bort arbetsyta",
|
||||||
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "大小写混合",
|
"password_validation_uppercase_and_lowercase": "大小写混合",
|
||||||
"please_verify_captcha": "请 验证 reCAPTCHA",
|
"please_verify_captcha": "请 验证 reCAPTCHA",
|
||||||
"privacy_policy": "隐私政策",
|
"privacy_policy": "隐私政策",
|
||||||
|
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
|
||||||
|
"product_updates_title": "产品更新",
|
||||||
|
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
|
||||||
|
"security_updates_title": "安全更新",
|
||||||
"terms_of_service": "服务条款",
|
"terms_of_service": "服务条款",
|
||||||
"title": "创建你的 Formbricks 账户"
|
"title": "创建你的 Formbricks 账户"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "文档",
|
"docs": "文档",
|
||||||
"documentation": "文档",
|
"documentation": "文档",
|
||||||
"domain": "域名",
|
"domain": "域名",
|
||||||
|
"done": "完成",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"draft": "草稿",
|
"draft": "草稿",
|
||||||
"duplicate": "复制",
|
"duplicate": "复制",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "添加 Webhook",
|
"add_webhook": "添加 Webhook",
|
||||||
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
||||||
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
||||||
|
"copy_secret_now": "复制您的签名密钥",
|
||||||
"created_by_third_party": "由 第三方 创建",
|
"created_by_third_party": "由 第三方 创建",
|
||||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||||
|
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||||
"please_check_console": "请查看控制台以获取更多详情",
|
"please_check_console": "请查看控制台以获取更多详情",
|
||||||
"please_enter_a_url": "请输入一个 URL",
|
"please_enter_a_url": "请输入一个 URL",
|
||||||
"response_created": "创建 响应",
|
"response_created": "创建 响应",
|
||||||
"response_finished": "响应 完成",
|
"response_finished": "响应 完成",
|
||||||
"response_updated": "更新 响应",
|
"response_updated": "更新 响应",
|
||||||
|
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
|
||||||
|
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
|
||||||
|
"signing_secret": "签名密钥",
|
||||||
"source": "来源",
|
"source": "来源",
|
||||||
"test_endpoint": "测试 端点",
|
"test_endpoint": "测试 端点",
|
||||||
"triggers": "触发器",
|
"triggers": "触发器",
|
||||||
"webhook_added_successfully": "Webhook 添加成功",
|
"webhook_added_successfully": "Webhook 添加成功",
|
||||||
|
"webhook_created": "Webhook 已创建",
|
||||||
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
||||||
"webhook_deleted_successfully": "Webhook 删除 成功",
|
"webhook_deleted_successfully": "Webhook 删除 成功",
|
||||||
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "移除 logo",
|
"remove_logo": "移除 logo",
|
||||||
"replace_logo": "替换 logo",
|
"replace_logo": "替换 logo",
|
||||||
"resend_invitation_email": "重新发送邀请邮件",
|
"resend_invitation_email": "重新发送邀请邮件",
|
||||||
|
"security_list_tip": "您已订阅我们的安全列表了吗?保持关注,保障您的实例安全!",
|
||||||
|
"security_list_tip_link": "点击此处注册。",
|
||||||
"share_invite_link": "分享邀请链接",
|
"share_invite_link": "分享邀请链接",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
|
||||||
"test_email_sent_successfully": "测试 邮件 发送 成功",
|
"test_email_sent_successfully": "测试 邮件 发送 成功",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "指派 =",
|
"assign": "指派 =",
|
||||||
"audience": "受众",
|
"audience": "受众",
|
||||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||||
|
"auto_save_disabled": "自动保存已禁用",
|
||||||
|
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||||
|
"auto_save_on": "自动保存已启用",
|
||||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
||||||
"calculate": "计算",
|
"calculate": "计算",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
||||||
|
"capture_ip_address": "记录IP地址",
|
||||||
|
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
|
||||||
"capture_new_action": "捕获 新动作",
|
"capture_new_action": "捕获 新动作",
|
||||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||||
"card_background_color": "卡片 的 背景 颜色",
|
"card_background_color": "卡片 的 背景 颜色",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "请 指定",
|
"please_specify": "请 指定",
|
||||||
"prevent_double_submission": "防止 重复 提交",
|
"prevent_double_submission": "防止 重复 提交",
|
||||||
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
|
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
|
||||||
|
"progress_saved": "进度已保存",
|
||||||
"protect_survey_with_pin": "使用 PIN 保护 调查",
|
"protect_survey_with_pin": "使用 PIN 保护 调查",
|
||||||
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
|
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
|
||||||
"publish": "发布",
|
"publish": "发布",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "下载答复时发生错误",
|
"error_downloading_responses": "下载答复时发生错误",
|
||||||
"first_name": "名字",
|
"first_name": "名字",
|
||||||
"how_to_identify_users": "如何 识别 用户",
|
"how_to_identify_users": "如何 识别 用户",
|
||||||
|
"ip_address": "IP地址",
|
||||||
"last_name": "姓",
|
"last_name": "姓",
|
||||||
"not_completed": "未完成 ⏳",
|
"not_completed": "未完成 ⏳",
|
||||||
"os": "操作系统",
|
"os": "操作系统",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "仅在 需要 设置 自定义 单次使用 ID 时 才 禁用。",
|
"url_encryption_description": "仅在 需要 设置 自定义 单次使用 ID 时 才 禁用。",
|
||||||
"url_encryption_label": "单次 使用 ID 的 URL 加密"
|
"url_encryption_label": "单次 使用 ID 的 URL 加密"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "调查脚本将在工作区级脚本的基础上运行。",
|
||||||
|
"add_to_workspace": "添加到工作区脚本",
|
||||||
|
"description": "为此调查添加跟踪脚本和像素代码",
|
||||||
|
"nav_title": "自定义 HTML",
|
||||||
|
"no_workspace_scripts": "尚未配置工作区级脚本。你可以在工作区设置 → 常规中添加。",
|
||||||
|
"placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||||
|
"replace_mode_description": "仅运行调查脚本,工作区脚本将被忽略。保持为空则不加载任何脚本。",
|
||||||
|
"replace_workspace": "替换工作区脚本",
|
||||||
|
"saved_successfully": "自定义脚本保存成功",
|
||||||
|
"script_mode": "脚本模式",
|
||||||
|
"security_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||||
|
"survey_scripts_description": "添加自定义 HTML 注入到此调查页面的<head>中。",
|
||||||
|
"survey_scripts_label": "调查专用脚本",
|
||||||
|
"workspace_scripts_label": "工作区脚本(继承)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "编辑 survey",
|
"alert_button": "编辑 survey",
|
||||||
"alert_description": "此 问卷 当前 配置 为 链接 问卷, 不 支持 动态 弹出 窗。 您 可以 在 问卷 编辑器 的 设置 选项 中 进行 修改。",
|
"alert_description": "此 问卷 当前 配置 为 链接 问卷, 不 支持 动态 弹出 窗。 您 可以 在 问卷 编辑器 的 设置 选项 中 进行 修改。",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。",
|
"cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。",
|
||||||
|
"custom_scripts": "自定义脚本",
|
||||||
|
"custom_scripts_card_description": "为此工作区内所有链接调查添加跟踪脚本和像素代码。",
|
||||||
|
"custom_scripts_description": "脚本将被注入到所有链接调查页面的<head>中。",
|
||||||
|
"custom_scripts_label": "HTML 脚本",
|
||||||
|
"custom_scripts_placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "自定义脚本更新成功",
|
||||||
|
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||||
"delete_workspace": "删除工作区",
|
"delete_workspace": "删除工作区",
|
||||||
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
|
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
|
||||||
"please_verify_captcha": "請驗證 reCAPTCHA",
|
"please_verify_captcha": "請驗證 reCAPTCHA",
|
||||||
"privacy_policy": "隱私權政策",
|
"privacy_policy": "隱私權政策",
|
||||||
|
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
|
||||||
|
"product_updates_title": "產品更新",
|
||||||
|
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
|
||||||
|
"security_updates_title": "安全更新",
|
||||||
"terms_of_service": "服務條款",
|
"terms_of_service": "服務條款",
|
||||||
"title": "建立您的 Formbricks 帳戶"
|
"title": "建立您的 Formbricks 帳戶"
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,7 @@
|
|||||||
"docs": "文件",
|
"docs": "文件",
|
||||||
"documentation": "文件",
|
"documentation": "文件",
|
||||||
"domain": "網域",
|
"domain": "網域",
|
||||||
|
"done": "完成",
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"draft": "草稿",
|
"draft": "草稿",
|
||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
@@ -783,20 +788,26 @@
|
|||||||
"add_webhook": "新增 Webhook",
|
"add_webhook": "新增 Webhook",
|
||||||
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
||||||
"all_current_and_new_surveys": "所有目前和新的問卷",
|
"all_current_and_new_surveys": "所有目前和新的問卷",
|
||||||
|
"copy_secret_now": "複製您的簽章密鑰",
|
||||||
"created_by_third_party": "由第三方建立",
|
"created_by_third_party": "由第三方建立",
|
||||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||||
|
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||||
"please_enter_a_url": "請輸入網址",
|
"please_enter_a_url": "請輸入網址",
|
||||||
"response_created": "已建立回應",
|
"response_created": "已建立回應",
|
||||||
"response_finished": "已完成回應",
|
"response_finished": "已完成回應",
|
||||||
"response_updated": "已更新回應",
|
"response_updated": "已更新回應",
|
||||||
|
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
|
||||||
|
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
|
||||||
|
"signing_secret": "簽章密鑰",
|
||||||
"source": "來源",
|
"source": "來源",
|
||||||
"test_endpoint": "測試端點",
|
"test_endpoint": "測試端點",
|
||||||
"triggers": "觸發器",
|
"triggers": "觸發器",
|
||||||
"webhook_added_successfully": "Webhook 已成功新增",
|
"webhook_added_successfully": "Webhook 已成功新增",
|
||||||
|
"webhook_created": "Webhook 已建立",
|
||||||
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
||||||
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
||||||
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
||||||
@@ -1008,6 +1019,8 @@
|
|||||||
"remove_logo": "移除標誌",
|
"remove_logo": "移除標誌",
|
||||||
"replace_logo": "取代標誌",
|
"replace_logo": "取代標誌",
|
||||||
"resend_invitation_email": "重新發送邀請電子郵件",
|
"resend_invitation_email": "重新發送邀請電子郵件",
|
||||||
|
"security_list_tip": "您已訂閱我們的安全名單了嗎?保持關注,確保您的實例安全!",
|
||||||
|
"security_list_tip_link": "請在此註冊。",
|
||||||
"share_invite_link": "分享邀請連結",
|
"share_invite_link": "分享邀請連結",
|
||||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
|
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
|
||||||
"test_email_sent_successfully": "測試電子郵件已成功發送",
|
"test_email_sent_successfully": "測試電子郵件已成功發送",
|
||||||
@@ -1169,6 +1182,9 @@
|
|||||||
"assign": "等於 =",
|
"assign": "等於 =",
|
||||||
"audience": "受眾",
|
"audience": "受眾",
|
||||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||||
|
"auto_save_disabled": "自動儲存已停用",
|
||||||
|
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||||
|
"auto_save_on": "自動儲存已啟用",
|
||||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||||
@@ -1190,6 +1206,8 @@
|
|||||||
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
||||||
"calculate": "計算",
|
"calculate": "計算",
|
||||||
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
||||||
|
"capture_ip_address": "擷取 IP 位址",
|
||||||
|
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
|
||||||
"capture_new_action": "擷取新操作",
|
"capture_new_action": "擷取新操作",
|
||||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||||
"card_background_color": "卡片背景顏色",
|
"card_background_color": "卡片背景顏色",
|
||||||
@@ -1448,6 +1466,7 @@
|
|||||||
"please_specify": "請指定",
|
"please_specify": "請指定",
|
||||||
"prevent_double_submission": "防止重複提交",
|
"prevent_double_submission": "防止重複提交",
|
||||||
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
|
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
|
||||||
|
"progress_saved": "進度已儲存",
|
||||||
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
|
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
|
||||||
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
|
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
|
||||||
"publish": "發布",
|
"publish": "發布",
|
||||||
@@ -1646,6 +1665,7 @@
|
|||||||
"error_downloading_responses": "下載回應時發生錯誤",
|
"error_downloading_responses": "下載回應時發生錯誤",
|
||||||
"first_name": "名字",
|
"first_name": "名字",
|
||||||
"how_to_identify_users": "如何識別使用者",
|
"how_to_identify_users": "如何識別使用者",
|
||||||
|
"ip_address": "IP 位址",
|
||||||
"last_name": "姓氏",
|
"last_name": "姓氏",
|
||||||
"not_completed": "未完成 ⏳",
|
"not_completed": "未完成 ⏳",
|
||||||
"os": "作業系統",
|
"os": "作業系統",
|
||||||
@@ -1690,6 +1710,22 @@
|
|||||||
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
|
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
|
||||||
"url_encryption_label": "單次使用 ID 的 URL 加密"
|
"url_encryption_label": "單次使用 ID 的 URL 加密"
|
||||||
},
|
},
|
||||||
|
"custom_html": {
|
||||||
|
"add_mode_description": "調查問卷腳本將會與工作區層級的腳本一同執行。",
|
||||||
|
"add_to_workspace": "加入至工作區腳本",
|
||||||
|
"description": "將追蹤腳本與像素碼加入此調查問卷",
|
||||||
|
"nav_title": "自訂 HTML",
|
||||||
|
"no_workspace_scripts": "尚未設定工作區層級腳本。您可以在「工作區設定」→「一般」中新增。",
|
||||||
|
"placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||||
|
"replace_mode_description": "僅執行調查問卷腳本,將忽略工作區腳本。若不需載入任何腳本,請保持空白。",
|
||||||
|
"replace_workspace": "取代工作區腳本",
|
||||||
|
"saved_successfully": "自訂腳本已成功儲存",
|
||||||
|
"script_mode": "腳本模式",
|
||||||
|
"security_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||||
|
"survey_scripts_description": "新增自訂 HTML 以注入至此調查問卷頁面的 <head>。",
|
||||||
|
"survey_scripts_label": "調查問卷專屬腳本",
|
||||||
|
"workspace_scripts_label": "工作區腳本(繼承)"
|
||||||
|
},
|
||||||
"dynamic_popup": {
|
"dynamic_popup": {
|
||||||
"alert_button": "編輯 問卷",
|
"alert_button": "編輯 問卷",
|
||||||
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
|
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
|
||||||
@@ -1929,6 +1965,13 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。",
|
"cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。",
|
||||||
|
"custom_scripts": "自訂腳本",
|
||||||
|
"custom_scripts_card_description": "將追蹤腳本與像素碼加入此工作區內所有連結調查問卷。",
|
||||||
|
"custom_scripts_description": "腳本將注入至所有連結調查問卷頁面的 <head>。",
|
||||||
|
"custom_scripts_label": "HTML 腳本",
|
||||||
|
"custom_scripts_placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||||
|
"custom_scripts_updated_successfully": "自訂腳本已成功更新",
|
||||||
|
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||||
"delete_workspace": "刪除工作區",
|
"delete_workspace": "刪除工作區",
|
||||||
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
||||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||||
|
|||||||
+26
-29
@@ -1,11 +1,15 @@
|
|||||||
import { Languages } from "lucide-react";
|
import { Languages } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
|
||||||
interface LanguageDropdownProps {
|
interface LanguageDropdownProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
@@ -14,38 +18,31 @@ interface LanguageDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||||
const [showLanguageSelect, setShowLanguageSelect] = useState(false);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||||
|
|
||||||
useClickOutside(containerRef, () => setShowLanguageSelect(false));
|
if (enabledLanguages.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
enabledLanguages.length > 1 && (
|
<DropdownMenu>
|
||||||
<div className="relative" ref={containerRef}>
|
<DropdownMenuTrigger asChild>
|
||||||
{showLanguageSelect && (
|
<Button variant="secondary" title="Select Language" aria-label="Select Language">
|
||||||
<div className="absolute top-12 z-30 max-h-64 max-w-48 overflow-auto rounded-lg border bg-slate-900 p-1 text-sm text-white">
|
|
||||||
{enabledLanguages.map((surveyLanguage) => (
|
|
||||||
<button
|
|
||||||
key={surveyLanguage.language.code}
|
|
||||||
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
|
||||||
onClick={() => {
|
|
||||||
setLanguage(surveyLanguage.language.code);
|
|
||||||
setShowLanguageSelect(false);
|
|
||||||
}}>
|
|
||||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
title="Select Language"
|
|
||||||
aria-label="Select Language"
|
|
||||||
onClick={() => setShowLanguageSelect(!showLanguageSelect)}>
|
|
||||||
<Languages className="h-5 w-5" />
|
<Languages className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DropdownMenuTrigger>
|
||||||
)
|
<DropdownMenuContent
|
||||||
|
className="max-h-64 max-w-48 overflow-auto bg-slate-900 p-1 text-sm text-white"
|
||||||
|
align="start">
|
||||||
|
{enabledLanguages.map((surveyLanguage) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={surveyLanguage.language.code}
|
||||||
|
className="w-full truncate rounded-md p-2 text-start text-white hover:cursor-pointer hover:bg-slate-700 focus:bg-slate-700"
|
||||||
|
onSelect={() => setLanguage(surveyLanguage.language.code)}>
|
||||||
|
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+5
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
|
|||||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{response.meta.ipAddress && (
|
||||||
|
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
|
||||||
|
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ZodRawShape, z } from "zod";
|
import { ZodRawShape, z } from "zod";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||||
@@ -67,7 +68,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
|||||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||||
|
|
||||||
if (schemas?.body) {
|
if (schemas?.body) {
|
||||||
const bodyData = await request.json();
|
let bodyData;
|
||||||
|
try {
|
||||||
|
bodyData = await request.json();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||||
|
return handleApiError(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
field: "error",
|
||||||
|
issue: "Malformed JSON input, please check your request body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const bodyResult = schemas.body.safeParse(bodyData);
|
const bodyResult = schemas.body.safeParse(bodyData);
|
||||||
|
|
||||||
if (!bodyResult.success) {
|
if (!bodyResult.success) {
|
||||||
|
|||||||
@@ -132,6 +132,71 @@ describe("apiWrapper", () => {
|
|||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should handle malformed JSON input in request body", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
method: "POST",
|
||||||
|
body: "{ invalid json }",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||||
|
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||||
|
|
||||||
|
const bodySchema = z.object({ key: z.string() });
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
const response = await apiWrapper({
|
||||||
|
request,
|
||||||
|
schemas: { body: bodySchema },
|
||||||
|
rateLimit: false,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
field: "error",
|
||||||
|
issue: "Malformed JSON input, please check your request body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty body when body schema is provided", async () => {
|
||||||
|
const request = new Request("http://localhost", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||||
|
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||||
|
|
||||||
|
const bodySchema = z.object({ key: z.string() });
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
const response = await apiWrapper({
|
||||||
|
request,
|
||||||
|
schemas: { body: bodySchema },
|
||||||
|
rateLimit: false,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||||
|
type: "bad_request",
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
field: "error",
|
||||||
|
issue: "Malformed JSON input, please check your request body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should parse query schema correctly", async () => {
|
test("should parse query schema correctly", async () => {
|
||||||
const request = new Request("http://localhost?key=value");
|
const request = new Request("http://localhost?key=value");
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { buildCommonFilterQuery } from "./utils";
|
import { buildCommonFilterQuery } from "./utils";
|
||||||
|
|
||||||
describe("buildCommonFilterQuery", () => {
|
describe("buildCommonFilterQuery", () => {
|
||||||
// Test for line 32: spread existing date filter when adding startDate
|
// Test for line 32: spread existing date filter when adding startDate
|
||||||
it("should preserve existing date filter when adding startDate", () => {
|
test("should preserve existing date filter when adding startDate", () => {
|
||||||
const query: Prisma.ResponseFindManyArgs = {
|
const query: Prisma.ResponseFindManyArgs = {
|
||||||
where: {
|
where: {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
@@ -23,7 +23,7 @@ describe("buildCommonFilterQuery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test for line 45: spread existing date filter when adding endDate
|
// Test for line 45: spread existing date filter when adding endDate
|
||||||
it("should preserve existing date filter when adding endDate", () => {
|
test("should preserve existing date filter when adding endDate", () => {
|
||||||
const query: Prisma.ResponseFindManyArgs = {
|
const query: Prisma.ResponseFindManyArgs = {
|
||||||
where: {
|
where: {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||||
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
|
|
||||||
|
// Supported expansion keys
|
||||||
|
export const ZResponseExpand = z.enum(["choiceIds", "questionHeadlines"]);
|
||||||
|
|
||||||
|
export type TResponseExpand = z.infer<typeof ZResponseExpand>;
|
||||||
|
|
||||||
|
// Schema for the expand query parameter (comma-separated list)
|
||||||
|
export const ZExpandParam = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => {
|
||||||
|
if (!val) return [];
|
||||||
|
return val.split(",").map((s) => s.trim());
|
||||||
|
})
|
||||||
|
.pipe(z.array(ZResponseExpand));
|
||||||
|
|
||||||
|
export type TExpandParam = z.infer<typeof ZExpandParam>;
|
||||||
|
|
||||||
|
// Expanded response data structure for a single answer
|
||||||
|
export type TExpandedValue = {
|
||||||
|
value: TResponseDataValue;
|
||||||
|
choiceIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expanded response data structure
|
||||||
|
export type TExpandedResponseData = {
|
||||||
|
[questionId: string]: TExpandedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Additional expansions that are added as separate fields
|
||||||
|
export type TResponseExpansions = {
|
||||||
|
questionHeadlines?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Choice element types that support choiceIds expansion
|
||||||
|
const CHOICE_ELEMENT_TYPES = ["multipleChoiceMulti", "multipleChoiceSingle", "ranking", "pictureSelection"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an element type supports choice ID expansion
|
||||||
|
*/
|
||||||
|
export const isChoiceElement = (element: TSurveyElement): boolean => {
|
||||||
|
return CHOICE_ELEMENT_TYPES.includes(element.type);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if element has choices property
|
||||||
|
*/
|
||||||
|
const hasChoices = (
|
||||||
|
element: TSurveyElement
|
||||||
|
): element is TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> } => {
|
||||||
|
return "choices" in element && Array.isArray(element.choices);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if element has headline property
|
||||||
|
*/
|
||||||
|
const hasHeadline = (
|
||||||
|
element: TSurveyElement
|
||||||
|
): element is TSurveyElement & { headline: Record<string, string> } => {
|
||||||
|
return "headline" in element && typeof element.headline === "object";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts choice IDs from a response value for choice-based questions
|
||||||
|
* @param responseValue - The response value (string for single choice, array for multi choice)
|
||||||
|
* @param element - The survey element containing choices
|
||||||
|
* @param language - The language to match against (defaults to "default")
|
||||||
|
* @returns Array of choice IDs
|
||||||
|
*/
|
||||||
|
export const extractChoiceIdsFromResponse = (
|
||||||
|
responseValue: TResponseDataValue,
|
||||||
|
element: TSurveyElement,
|
||||||
|
language: string = "default"
|
||||||
|
): string[] => {
|
||||||
|
if (!isChoiceElement(element) || !responseValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture selection already stores IDs directly
|
||||||
|
if (element.type === "pictureSelection") {
|
||||||
|
if (Array.isArray(responseValue)) {
|
||||||
|
return responseValue.filter((id): id is string => typeof id === "string");
|
||||||
|
}
|
||||||
|
return typeof responseValue === "string" ? [responseValue] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other choice types, we need to map labels to IDs
|
||||||
|
if (!hasChoices(element)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const findChoiceByLabel = (label: string): string => {
|
||||||
|
const choice = element.choices.find((c) => {
|
||||||
|
// Try exact language match first
|
||||||
|
if (c.label[language] === label) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fall back to checking all language values
|
||||||
|
return Object.values(c.label).includes(label);
|
||||||
|
});
|
||||||
|
return choice?.id ?? "other";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(responseValue)) {
|
||||||
|
return responseValue.filter((v): v is string => typeof v === "string" && v !== "").map(findChoiceByLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof responseValue === "string") {
|
||||||
|
return [findChoiceByLabel(responseValue)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand response data with choice IDs
|
||||||
|
* @param data - The response data object
|
||||||
|
* @param survey - The survey definition
|
||||||
|
* @param language - The language code for label matching
|
||||||
|
* @returns Expanded response data with choice IDs
|
||||||
|
*/
|
||||||
|
export const expandWithChoiceIds = (
|
||||||
|
data: TResponseData,
|
||||||
|
survey: TSurvey,
|
||||||
|
language: string = "default"
|
||||||
|
): TExpandedResponseData => {
|
||||||
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
|
const expandedData: TExpandedResponseData = {};
|
||||||
|
|
||||||
|
for (const [questionId, value] of Object.entries(data)) {
|
||||||
|
const element = elements.find((e) => e.id === questionId);
|
||||||
|
|
||||||
|
if (element && isChoiceElement(element)) {
|
||||||
|
const choiceIds = extractChoiceIdsFromResponse(value, element, language);
|
||||||
|
expandedData[questionId] = {
|
||||||
|
value,
|
||||||
|
...(choiceIds.length > 0 && { choiceIds }),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
expandedData[questionId] = { value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate question headlines map
|
||||||
|
* @param data - The response data object
|
||||||
|
* @param survey - The survey definition
|
||||||
|
* @param language - The language code for localization
|
||||||
|
* @returns Record mapping question IDs to their headlines
|
||||||
|
*/
|
||||||
|
export const getQuestionHeadlines = (
|
||||||
|
data: TResponseData,
|
||||||
|
survey: TSurvey,
|
||||||
|
language: string = "default"
|
||||||
|
): Record<string, string> => {
|
||||||
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
|
const headlines: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const questionId of Object.keys(data)) {
|
||||||
|
const element = elements.find((e) => e.id === questionId);
|
||||||
|
if (element && hasHeadline(element)) {
|
||||||
|
headlines[questionId] = getLocalizedValue(element.headline, language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headlines;
|
||||||
|
};
|
||||||
@@ -11,7 +11,8 @@ import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/type
|
|||||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||||
operationId: "getResponses",
|
operationId: "getResponses",
|
||||||
summary: "Get responses",
|
summary: "Get responses",
|
||||||
description: "Gets responses from the database.",
|
description:
|
||||||
|
"Gets responses from the database. Use the `expand` parameter to enrich response data with additional information like choice IDs (for language-agnostic processing) or question headlines.",
|
||||||
requestParams: {
|
requestParams: {
|
||||||
query: ZGetResponsesFilter.sourceType(),
|
query: ZGetResponsesFilter.sourceType(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,4 +93,5 @@ export const responseFilter: TGetResponsesFilter = {
|
|||||||
skip: 0,
|
skip: 0,
|
||||||
sortBy: "createdAt",
|
sortBy: "createdAt",
|
||||||
order: "asc",
|
order: "asc",
|
||||||
|
expand: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Response } from "@prisma/client";
|
||||||
|
import { TResponseData } from "@formbricks/types/responses";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import {
|
||||||
|
TExpandParam,
|
||||||
|
TExpandedResponseData,
|
||||||
|
TResponseExpansions,
|
||||||
|
expandWithChoiceIds,
|
||||||
|
getQuestionHeadlines,
|
||||||
|
} from "./expand";
|
||||||
|
|
||||||
|
export type TTransformedResponse = Omit<Response, "data"> & {
|
||||||
|
data: TResponseData | TExpandedResponseData;
|
||||||
|
expansions?: TResponseExpansions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a response based on requested expansions
|
||||||
|
* @param response - The raw response from the database
|
||||||
|
* @param survey - The survey definition
|
||||||
|
* @param expand - Array of expansion keys to apply
|
||||||
|
* @returns Transformed response with requested expansions
|
||||||
|
*/
|
||||||
|
export const transformResponse = (
|
||||||
|
response: Response,
|
||||||
|
survey: TSurvey,
|
||||||
|
expand: TExpandParam
|
||||||
|
): TTransformedResponse => {
|
||||||
|
const language = response.language ?? "default";
|
||||||
|
const data = response.data as TResponseData;
|
||||||
|
|
||||||
|
let transformedData: TResponseData | TExpandedResponseData = data;
|
||||||
|
const expansions: TResponseExpansions = {};
|
||||||
|
|
||||||
|
// Apply choiceIds expansion
|
||||||
|
if (expand.includes("choiceIds")) {
|
||||||
|
transformedData = expandWithChoiceIds(data, survey, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply questionHeadlines expansion
|
||||||
|
if (expand.includes("questionHeadlines")) {
|
||||||
|
expansions.questionHeadlines = getQuestionHeadlines(data, survey, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: transformedData,
|
||||||
|
...(Object.keys(expansions).length > 0 && { expansions }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform multiple responses with caching of survey lookups
|
||||||
|
* @param responses - Array of raw responses from the database
|
||||||
|
* @param expand - Array of expansion keys to apply
|
||||||
|
* @param getSurvey - Function to fetch survey by ID
|
||||||
|
* @returns Array of transformed responses
|
||||||
|
*/
|
||||||
|
export const transformResponses = async (
|
||||||
|
responses: Response[],
|
||||||
|
expand: TExpandParam,
|
||||||
|
getSurvey: (surveyId: string) => Promise<TSurvey | null>
|
||||||
|
): Promise<TTransformedResponse[]> => {
|
||||||
|
if (expand.length === 0) {
|
||||||
|
// No expansion requested, return as-is
|
||||||
|
return responses as TTransformedResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache surveys to avoid duplicate lookups
|
||||||
|
const surveyCache = new Map<string, TSurvey | null>();
|
||||||
|
|
||||||
|
const transformed = await Promise.all(
|
||||||
|
responses.map(async (response) => {
|
||||||
|
let survey = surveyCache.get(response.surveyId);
|
||||||
|
|
||||||
|
if (survey === undefined) {
|
||||||
|
survey = await getSurvey(response.surveyId);
|
||||||
|
surveyCache.set(response.surveyId, survey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!survey) {
|
||||||
|
// Survey not found, return response unchanged
|
||||||
|
return response as TTransformedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformResponse(response, survey, expand);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Response } from "@prisma/client";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
import { responses } from "@/modules/api/v2/lib/response";
|
import { responses } from "@/modules/api/v2/lib/response";
|
||||||
@@ -13,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
|||||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||||
|
import { transformResponses } from "./lib/transform";
|
||||||
|
|
||||||
export const GET = async (request: NextRequest) =>
|
export const GET = async (request: NextRequest) =>
|
||||||
authenticatedApiClient({
|
authenticatedApiClient({
|
||||||
@@ -34,16 +35,17 @@ export const GET = async (request: NextRequest) =>
|
|||||||
(permission) => permission.environmentId
|
(permission) => permission.environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const environmentResponses: Response[] = [];
|
|
||||||
const res = await getResponses(environmentIds, query);
|
const res = await getResponses(environmentIds, query);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return handleApiError(request, res.error);
|
return handleApiError(request, res.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
environmentResponses.push(...res.data.data);
|
// Transform responses if expansion is requested
|
||||||
|
const expand = query.expand ?? [];
|
||||||
|
const transformedResponses = await transformResponses(res.data.data, expand, getSurvey);
|
||||||
|
|
||||||
return responses.successResponse({ data: environmentResponses });
|
return responses.successResponse({ data: transformedResponses, meta: res.data.meta });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||||
|
import { ZExpandParam } from "@/modules/api/v2/management/responses/lib/expand";
|
||||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||||
|
|
||||||
export const ZGetResponsesFilter = ZGetFilter.extend({
|
export const ZGetResponsesFilter = ZGetFilter.extend({
|
||||||
surveyId: z.string().cuid2().optional(),
|
surveyId: z.string().cuid2().optional(),
|
||||||
contactId: z.string().optional(),
|
contactId: z.string().optional(),
|
||||||
|
expand: ZExpandParam,
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
environmentId: true,
|
environmentId: true,
|
||||||
|
secret: true,
|
||||||
}).openapi({
|
}).openapi({
|
||||||
ref: "webhookUpdate",
|
ref: "webhookUpdate",
|
||||||
description: "A webhook to update.",
|
description: "A webhook to update.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma, Webhook } from "@prisma/client";
|
import { Prisma, Webhook } from "@prisma/client";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||||
|
import { generateWebhookSecret } from "@/lib/crypto";
|
||||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||||
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
|||||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
|
||||||
const prismaData: Prisma.WebhookCreateInput = {
|
const prismaData: Prisma.WebhookCreateInput = {
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
|||||||
source,
|
source,
|
||||||
triggers,
|
triggers,
|
||||||
surveyIds,
|
surveyIds,
|
||||||
|
secret,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdWebhook = await prisma.webhook.create({
|
const createdWebhook = await prisma.webhook.create({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
|||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||||
|
|
||||||
const ZCreatedUser = ZUser.pick({
|
const ZCreatedUser = ZUser.pick({
|
||||||
@@ -44,6 +45,9 @@ const ZCreateUserAction = z.object({
|
|||||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||||
"CAPTCHA verification required"
|
"CAPTCHA verification required"
|
||||||
),
|
),
|
||||||
|
isFormbricksCloud: z.boolean(),
|
||||||
|
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||||
|
subscribeToProductUpdates: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||||
@@ -191,6 +195,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
|||||||
parsedInput.inviteToken,
|
parsedInput.inviteToken,
|
||||||
parsedInput.emailVerificationDisabled
|
parsedInput.emailVerificationDisabled
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: user.email,
|
||||||
|
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||||
|
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||||
|
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createUserAction } from "@/modules/auth/signup/actions";
|
|||||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||||
@@ -48,6 +49,7 @@ interface SignupFormProps {
|
|||||||
samlTenant: string;
|
samlTenant: string;
|
||||||
samlProduct: string;
|
samlProduct: string;
|
||||||
turnstileSiteKey?: string;
|
turnstileSiteKey?: string;
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SignupForm = ({
|
export const SignupForm = ({
|
||||||
@@ -69,6 +71,7 @@ export const SignupForm = ({
|
|||||||
samlTenant,
|
samlTenant,
|
||||||
samlProduct,
|
samlProduct,
|
||||||
turnstileSiteKey,
|
turnstileSiteKey,
|
||||||
|
isFormbricksCloud,
|
||||||
}: SignupFormProps) => {
|
}: SignupFormProps) => {
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -76,6 +79,8 @@ export const SignupForm = ({
|
|||||||
const inviteToken = searchParams?.get("inviteToken");
|
const inviteToken = searchParams?.get("inviteToken");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [turnstileToken, setTurnstileToken] = useState<string>();
|
const [turnstileToken, setTurnstileToken] = useState<string>();
|
||||||
|
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
|
||||||
|
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
|
||||||
|
|
||||||
const turnstile = useTurnstile();
|
const turnstile = useTurnstile();
|
||||||
|
|
||||||
@@ -110,6 +115,9 @@ export const SignupForm = ({
|
|||||||
inviteToken: inviteToken ?? "",
|
inviteToken: inviteToken ?? "",
|
||||||
emailVerificationDisabled,
|
emailVerificationDisabled,
|
||||||
turnstileToken,
|
turnstileToken,
|
||||||
|
isFormbricksCloud,
|
||||||
|
subscribeToSecurityUpdates,
|
||||||
|
subscribeToProductUpdates,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||||
@@ -239,6 +247,43 @@ export const SignupForm = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showLogin &&
|
||||||
|
(isFormbricksCloud ? (
|
||||||
|
<label
|
||||||
|
htmlFor="product-updates"
|
||||||
|
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||||
|
<Checkbox
|
||||||
|
id="product-updates"
|
||||||
|
checked={subscribeToProductUpdates}
|
||||||
|
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
|
||||||
|
className="mt-0.5 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
{t("auth.signup.product_updates_title")}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label
|
||||||
|
htmlFor="security-updates"
|
||||||
|
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||||
|
<Checkbox
|
||||||
|
id="security-updates"
|
||||||
|
checked={subscribeToSecurityUpdates}
|
||||||
|
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
|
||||||
|
className="mt-0.5 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
{t("auth.signup.security_updates_title")}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
{showLogin && (
|
{showLogin && (
|
||||||
<Button
|
<Button
|
||||||
data-testid="signup-submit"
|
data-testid="signup-submit"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
EMAIL_VERIFICATION_DISABLED,
|
EMAIL_VERIFICATION_DISABLED,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
|
IS_FORMBRICKS_CLOUD,
|
||||||
IS_TURNSTILE_CONFIGURED,
|
IS_TURNSTILE_CONFIGURED,
|
||||||
OIDC_DISPLAY_NAME,
|
OIDC_DISPLAY_NAME,
|
||||||
OIDC_OAUTH_ENABLED,
|
OIDC_OAUTH_ENABLED,
|
||||||
@@ -76,6 +77,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
|||||||
samlTenant={SAML_TENANT}
|
samlTenant={SAML_TENANT}
|
||||||
samlProduct={SAML_PRODUCT}
|
samlProduct={SAML_PRODUCT}
|
||||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
/>
|
/>
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
@@ -48,40 +48,40 @@ export const ContactDataView = ({
|
|||||||
);
|
);
|
||||||
}, [contactAttributeKeys]);
|
}, [contactAttributeKeys]);
|
||||||
|
|
||||||
|
// Fetch contacts from offset 0 with current search value
|
||||||
|
const fetchContactsFromStart = useCallback(async () => {
|
||||||
|
setIsDataLoaded(false);
|
||||||
|
try {
|
||||||
|
setHasMore(true);
|
||||||
|
const contactsResponse = await getContactsAction({
|
||||||
|
environmentId: environment.id,
|
||||||
|
offset: 0,
|
||||||
|
searchValue,
|
||||||
|
});
|
||||||
|
if (contactsResponse?.data) {
|
||||||
|
setContacts(contactsResponse.data);
|
||||||
|
}
|
||||||
|
if (contactsResponse?.data && contactsResponse.data.length < itemsPerPage) {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contacts:", error);
|
||||||
|
toast.error("Error fetching contacts. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsDataLoaded(true);
|
||||||
|
}
|
||||||
|
}, [environment.id, itemsPerPage, searchValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFirstRender.current) {
|
if (!isFirstRender.current) {
|
||||||
const fetchData = async () => {
|
const debouncedFetchData = debounce(fetchContactsFromStart, 300);
|
||||||
setIsDataLoaded(false);
|
|
||||||
try {
|
|
||||||
setHasMore(true);
|
|
||||||
const getPersonActionData = await getContactsAction({
|
|
||||||
environmentId: environment.id,
|
|
||||||
offset: 0,
|
|
||||||
searchValue,
|
|
||||||
});
|
|
||||||
const personData = getPersonActionData?.data;
|
|
||||||
if (getPersonActionData?.data) {
|
|
||||||
setContacts(getPersonActionData.data);
|
|
||||||
}
|
|
||||||
if (personData && personData.length < itemsPerPage) {
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching people data:", error);
|
|
||||||
toast.error("Error fetching people data. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsDataLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debouncedFetchData = debounce(fetchData, 300);
|
|
||||||
debouncedFetchData();
|
debouncedFetchData();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
debouncedFetchData.cancel();
|
debouncedFetchData.cancel();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [environment.id, itemsPerPage, searchValue]);
|
}, [fetchContactsFromStart]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstRender.current) {
|
if (isFirstRender.current) {
|
||||||
@@ -147,6 +147,7 @@ export const ContactDataView = ({
|
|||||||
setSearchValue={setSearchValue}
|
setSearchValue={setSearchValue}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
|
refreshContacts={fetchContactsFromStart}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface ContactsTableProps {
|
|||||||
setSearchValue: (value: string) => void;
|
setSearchValue: (value: string) => void;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
|
refreshContacts: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContactsTable = ({
|
export const ContactsTable = ({
|
||||||
@@ -56,6 +57,7 @@ export const ContactsTable = ({
|
|||||||
setSearchValue,
|
setSearchValue,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isQuotasAllowed,
|
isQuotasAllowed,
|
||||||
|
refreshContacts,
|
||||||
}: ContactsTableProps) => {
|
}: ContactsTableProps) => {
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
@@ -235,6 +237,7 @@ export const ContactsTable = ({
|
|||||||
type="contact"
|
type="contact"
|
||||||
deleteAction={deleteContact}
|
deleteAction={deleteContact}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
|
onRefresh={refreshContacts}
|
||||||
leftContent={
|
leftContent={
|
||||||
<div className="w-64">
|
<div className="w-64">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/env", () => ({
|
||||||
env: {
|
env: {
|
||||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||||
|
ENVIRONMENT: "production",
|
||||||
VERCEL_URL: "some.vercel.url",
|
VERCEL_URL: "some.vercel.url",
|
||||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||||
HTTPS_PROXY: undefined,
|
HTTPS_PROXY: undefined,
|
||||||
@@ -690,4 +691,61 @@ describe("License Core Logic", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Environment-based endpoint selection", () => {
|
||||||
|
test("should use staging endpoint when ENVIRONMENT is staging", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("@/lib/env", () => ({
|
||||||
|
env: {
|
||||||
|
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||||
|
ENVIRONMENT: "staging",
|
||||||
|
HTTPS_PROXY: undefined,
|
||||||
|
HTTP_PROXY: undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetch = (await import("node-fetch")).default as Mock;
|
||||||
|
|
||||||
|
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||||
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||||
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
status: "active",
|
||||||
|
features: {
|
||||||
|
isMultiOrgEnabled: true,
|
||||||
|
projects: 5,
|
||||||
|
twoFactorAuth: true,
|
||||||
|
sso: true,
|
||||||
|
whitelabel: true,
|
||||||
|
removeBranding: true,
|
||||||
|
contacts: true,
|
||||||
|
ai: true,
|
||||||
|
saml: true,
|
||||||
|
spamProtection: true,
|
||||||
|
auditLogs: true,
|
||||||
|
multiLanguageSurveys: true,
|
||||||
|
accessControl: true,
|
||||||
|
quotas: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Re-import the module to apply the new mock
|
||||||
|
const { fetchLicense } = await import("./license");
|
||||||
|
await fetchLicense();
|
||||||
|
|
||||||
|
// Verify the staging endpoint was called
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"https://staging.ee.formbricks.com/api/licenses/check",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ const CONFIG = {
|
|||||||
RETRY_DELAY_MS: 1000,
|
RETRY_DELAY_MS: 1000,
|
||||||
},
|
},
|
||||||
API: {
|
API: {
|
||||||
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
|
ENDPOINT:
|
||||||
|
env.ENVIRONMENT === "staging"
|
||||||
|
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||||
|
: "https://ee.formbricks.com/api/licenses/check",
|
||||||
|
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||||
TIMEOUT_MS: 5000,
|
TIMEOUT_MS: 5000,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
|
||||||
|
|
||||||
|
vi.mock("@formbricks/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
|
validateInputs: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe("subscribeToMailingList", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should successfully subscribe to security mailing list", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
const result = await subscribeToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
listId: "security",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "test@example.com" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{ listId: "security" },
|
||||||
|
"Successfully subscribed to security mailing list"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should successfully subscribe to product-updates mailing list", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
const result = await subscribeToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
listId: "product-updates",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "test@example.com" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when API returns non-ok response", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||||
|
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await subscribeToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
listId: "security",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ status: 400, error: "Bad Request" },
|
||||||
|
"Failed to subscribe to security mailing list"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error when fetch throws an error", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
const result = await subscribeToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
listId: "security",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.any(Error),
|
||||||
|
"Error subscribing to security mailing list"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return timeout error when request times out", async () => {
|
||||||
|
const abortError = new Error("Aborted");
|
||||||
|
abortError.name = "AbortError";
|
||||||
|
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
|
||||||
|
|
||||||
|
const result = await subscribeToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
listId: "security",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: "Request timed out" });
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
{ listId: "security" },
|
||||||
|
"Mailing subscription request timed out"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subscribeUserToMailingList", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: true,
|
||||||
|
subscribeToProductUpdates: true,
|
||||||
|
subscribeToSecurityUpdates: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: true,
|
||||||
|
subscribeToProductUpdates: false,
|
||||||
|
subscribeToSecurityUpdates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: false,
|
||||||
|
subscribeToSecurityUpdates: true,
|
||||||
|
subscribeToProductUpdates: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: false,
|
||||||
|
subscribeToSecurityUpdates: false,
|
||||||
|
subscribeToProductUpdates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not subscribe when both subscription flags are undefined", async () => {
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should prioritize product-updates for cloud users even if security is also true", async () => {
|
||||||
|
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
|
||||||
|
await subscribeUserToMailingList({
|
||||||
|
email: "test@example.com",
|
||||||
|
isFormbricksCloud: true,
|
||||||
|
subscribeToProductUpdates: true,
|
||||||
|
subscribeToSecurityUpdates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should only call product-updates endpoint for cloud users
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
|
||||||
|
export type TMailingListId = "security" | "product-updates";
|
||||||
|
|
||||||
|
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
|
||||||
|
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||||
|
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EE_SERVER_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
interface TSubscribeToMailingListParams {
|
||||||
|
email: TUserEmail;
|
||||||
|
listId: TMailingListId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe a user to a mailing list via the EE server
|
||||||
|
* @param email - The user's email address
|
||||||
|
* @param listId - The mailing list ID ("security" or "product-updates")
|
||||||
|
*/
|
||||||
|
export const subscribeToMailingList = async ({
|
||||||
|
email,
|
||||||
|
listId,
|
||||||
|
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
validateInputs([email, ZUserEmail.toLowerCase()]);
|
||||||
|
|
||||||
|
const endpoint = MAILING_LIST_ENDPOINTS[listId];
|
||||||
|
if (!endpoint) {
|
||||||
|
logger.error({ listId }, "Invalid mailing list ID");
|
||||||
|
return { success: false, error: "Invalid mailing list ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error(
|
||||||
|
{ status: response.status, error: errorText },
|
||||||
|
`Failed to subscribe to ${listId} mailing list`
|
||||||
|
);
|
||||||
|
return { success: false, error: `Failed to subscribe: ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
logger.error({ listId }, "Mailing subscription request timed out");
|
||||||
|
return { success: false, error: "Request timed out" };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(error, `Error subscribing to ${listId} mailing list`);
|
||||||
|
return { success: false, error: "Failed to subscribe to mailing list" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribeUserToMailingList = async ({
|
||||||
|
email,
|
||||||
|
isFormbricksCloud,
|
||||||
|
subscribeToSecurityUpdates,
|
||||||
|
subscribeToProductUpdates,
|
||||||
|
}: {
|
||||||
|
email: TUserEmail;
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
|
subscribeToSecurityUpdates?: boolean;
|
||||||
|
subscribeToProductUpdates?: boolean;
|
||||||
|
}): Promise<void> => {
|
||||||
|
if (isFormbricksCloud && subscribeToProductUpdates) {
|
||||||
|
await subscribeToMailingList({ email, listId: "product-updates" });
|
||||||
|
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
|
||||||
|
await subscribeToMailingList({ email, listId: "security" });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -67,7 +67,7 @@ const validateLanguages = (languages: Language[], t: TFunction) => {
|
|||||||
// (e.g. alias "nl" pointing to a non-Dutch language) which later breaks the
|
// (e.g. alias "nl" pointing to a non-Dutch language) which later breaks the
|
||||||
// dropdowns that rely on ISO identifiers.
|
// dropdowns that rely on ISO identifiers.
|
||||||
for (const alias of languageAliases) {
|
for (const alias of languageAliases) {
|
||||||
if (iso639Languages.some((language) => language.alpha2 === alias && !languageCodes.includes(alias))) {
|
if (iso639Languages.some((language) => language.code === alias && !languageCodes.includes(alias))) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("environments.workspace.languages.conflict_between_selected_alias_and_another_language"),
|
t("environments.workspace.languages.conflict_between_selected_alias_and_another_language"),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedOption, setSelectedOption] = useState(
|
const [selectedOption, setSelectedOption] = useState(
|
||||||
iso639Languages.find((isoLang) => isoLang.alpha2 === language.code)
|
iso639Languages.find((isoLang) => isoLang.code === language.code)
|
||||||
);
|
);
|
||||||
const items = iso639Languages;
|
const items = iso639Languages;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
|||||||
|
|
||||||
const handleOptionSelect = (option: TIso639Language) => {
|
const handleOptionSelect = (option: TIso639Language) => {
|
||||||
setSelectedOption(option);
|
setSelectedOption(option);
|
||||||
onLanguageChange({ ...language, code: option.alpha2 || "" });
|
onLanguageChange({ ...language, code: option.code || "" });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
|||||||
{filteredItems.map((item) => (
|
{filteredItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||||
key={item.alpha2}
|
key={item.code}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleOptionSelect(item);
|
handleOptionSelect(item);
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
|||||||
darkOverlay: true,
|
darkOverlay: true,
|
||||||
styling: true,
|
styling: true,
|
||||||
logo: true,
|
logo: true,
|
||||||
|
customHeadScripts: true,
|
||||||
// All project environments
|
// All project environments
|
||||||
environments: {
|
environments: {
|
||||||
select: {
|
select: {
|
||||||
@@ -222,6 +223,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
|||||||
darkOverlay: data.project.darkOverlay,
|
darkOverlay: data.project.darkOverlay,
|
||||||
styling: data.project.styling,
|
styling: data.project.styling,
|
||||||
logo: data.project.logo,
|
logo: data.project.logo,
|
||||||
|
customHeadScripts: data.project.customHeadScripts,
|
||||||
environments: data.project.environments,
|
environments: data.project.environments,
|
||||||
},
|
},
|
||||||
organization: {
|
organization: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PipelineTriggers } from "@prisma/client";
|
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Webhook } from "lucide-react";
|
import { Webhook as WebhookIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
||||||
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
||||||
|
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
|
||||||
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||||
|
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||||
|
|
||||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
});
|
});
|
||||||
if (createWebhookActionResult?.data) {
|
if (createWebhookActionResult?.data) {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setOpenWithStates(false);
|
setCreatedWebhook(createWebhookActionResult.data);
|
||||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||||
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setOpenWithStates = (isOpen: boolean) => {
|
const resetAndClose = () => {
|
||||||
setOpen(isOpen);
|
setOpen(false);
|
||||||
reset();
|
reset();
|
||||||
setTestEndpointInput("");
|
setTestEndpointInput("");
|
||||||
setEndpointAccessible(undefined);
|
setEndpointAccessible(undefined);
|
||||||
setSelectedSurveys([]);
|
setSelectedSurveys([]);
|
||||||
setSelectedTriggers([]);
|
setSelectedTriggers([]);
|
||||||
setSelectedAllSurveys(false);
|
setSelectedAllSurveys(false);
|
||||||
|
setCreatedWebhook(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show success dialog with secret after webhook creation
|
||||||
|
if (createdWebhook) {
|
||||||
|
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<Webhook />
|
<WebhookIcon />
|
||||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||||
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
|||||||
</DialogBody>
|
</DialogBody>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setOpenWithStates(false);
|
|
||||||
}}>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={creatingWebhook}>
|
<Button type="submit" loading={creatingWebhook}>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Webhook } from "@prisma/client";
|
||||||
|
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
|
interface WebhookCreatedModalProps {
|
||||||
|
open: boolean;
|
||||||
|
webhook: Webhook;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebhookCreatedModal = ({ open, webhook, onClose }: WebhookCreatedModalProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<CheckIcon className="h-6 w-6 text-green-500" />
|
||||||
|
<DialogTitle>{t("environments.integrations.webhooks.webhook_created")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("environments.integrations.webhooks.copy_secret_now")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="space-y-4 pb-4">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Label>{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||||
|
<div className="mt-1 flex">
|
||||||
|
<Input type="text" readOnly value={webhook.secret ?? ""} className="font-mono text-sm" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 whitespace-nowrap"
|
||||||
|
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
{t("common.copied")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
{t("common.copy")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{t("environments.integrations.webhooks.secret_copy_warning")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
|
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||||
|
<ExternalLinkIcon className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" onClick={onClose}>
|
||||||
|
{t("common.done")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -48,6 +48,15 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
|||||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||||
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -113,6 +122,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
|||||||
toast.error(t("common.please_select_at_least_one_survey"));
|
toast.error(t("common.please_select_at_least_one_survey"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||||
if (!endpointHitSuccessfully) {
|
if (!endpointHitSuccessfully) {
|
||||||
return;
|
return;
|
||||||
@@ -196,6 +206,60 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{webhook.secret && (
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Label htmlFor="secret">{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||||
|
<div className="mt-1 flex">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type={showSecret ? "text" : "password"}
|
||||||
|
id="secret"
|
||||||
|
readOnly
|
||||||
|
value={webhook.secret}
|
||||||
|
className="pr-10 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
|
||||||
|
onClick={() => setShowSecret(!showSecret)}>
|
||||||
|
{showSecret ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 whitespace-nowrap"
|
||||||
|
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
{t("common.copied")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
{t("common.copy")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{t("environments.integrations.webhooks.secret_description")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||||
|
target="_blank"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
|
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||||
|
<ExternalLinkIcon className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
|
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
|
||||||
<TriggerCheckboxGroup
|
<TriggerCheckboxGroup
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const WebhookTable = ({
|
|||||||
surveyIds: [],
|
surveyIds: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
secret: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Prisma, Webhook } from "@prisma/client";
|
import { Prisma, Webhook } from "@prisma/client";
|
||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
ResourceNotFoundError,
|
ResourceNotFoundError,
|
||||||
UnknownError,
|
UnknownError,
|
||||||
} from "@formbricks/types/errors";
|
} from "@formbricks/types/errors";
|
||||||
|
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||||
import { TWebhookInput } from "../types/webhooks";
|
import { TWebhookInput } from "../types/webhooks";
|
||||||
@@ -59,15 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
|
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||||
try {
|
try {
|
||||||
if (isDiscordWebhook(webhookInput.url)) {
|
if (isDiscordWebhook(webhookInput.url)) {
|
||||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||||
}
|
}
|
||||||
await prisma.webhook.create({
|
|
||||||
|
const secret = generateWebhookSecret();
|
||||||
|
|
||||||
|
const webhook = await prisma.webhook.create({
|
||||||
data: {
|
data: {
|
||||||
...webhookInput,
|
...webhookInput,
|
||||||
surveyIds: webhookInput.surveyIds || [],
|
surveyIds: webhookInput.surveyIds || [],
|
||||||
|
secret,
|
||||||
environment: {
|
environment: {
|
||||||
connect: {
|
connect: {
|
||||||
id: environmentId,
|
id: environmentId,
|
||||||
@@ -76,7 +82,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return webhook;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
@@ -121,13 +127,22 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
|
|||||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webhookMessageId = uuidv7();
|
||||||
|
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const body = JSON.stringify({ event: "testEndpoint" });
|
||||||
|
|
||||||
|
// Generate a temporary test secret and signature for consistency with actual webhooks
|
||||||
|
const testSecret = generateWebhookSecret();
|
||||||
|
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body,
|
||||||
event: "testEndpoint",
|
|
||||||
}),
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"webhook-id": webhookMessageId,
|
||||||
|
"webhook-timestamp": webhookTimestamp.toString(),
|
||||||
|
"webhook-signature": signature,
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export const AddApiKeyModal = ({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="min-w-[8rem]">
|
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
|
||||||
{projectOptions.map((option) => (
|
{projectOptions.map((option) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -286,7 +286,7 @@ export const AddApiKeyModal = ({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="min-w-[8rem] capitalize">
|
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto capitalize">
|
||||||
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
|
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={env.id}
|
key={env.id}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export const APIKeysPage = async (props) => {
|
|||||||
|
|
||||||
const projects = await getProjectsByOrganizationId(organization.id);
|
const projects = await getProjectsByOrganizationId(organization.id);
|
||||||
|
|
||||||
const isNotOwner = currentUserMembership.role !== "owner";
|
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
|
||||||
|
|
||||||
if (isNotOwner) throw new Error(t("common.not_authorized"));
|
if (!canAccessApiKeys) throw new Error(t("common.not_authorized"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -38,7 +38,7 @@ export const APIKeysPage = async (props) => {
|
|||||||
<ApiKeyList
|
<ApiKeyList
|
||||||
organizationId={organization.id}
|
organizationId={organization.id}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isReadOnly={isNotOwner}
|
isReadOnly={!canAccessApiKeys}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { updateProjectAction } from "../../actions";
|
||||||
|
|
||||||
|
interface CustomScriptsFormProps {
|
||||||
|
project: TProject;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZCustomScriptsInput = z.object({
|
||||||
|
customHeadScripts: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCustomScriptsFormValues = z.infer<typeof ZCustomScriptsInput>;
|
||||||
|
|
||||||
|
export const CustomScriptsForm: React.FC<CustomScriptsFormProps> = ({ project, isReadOnly }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = useForm<TCustomScriptsFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
customHeadScripts: project.customHeadScripts ?? "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZCustomScriptsInput),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isDirty, isSubmitting } = form.formState;
|
||||||
|
|
||||||
|
const updateCustomScripts: SubmitHandler<TCustomScriptsFormValues> = async (data) => {
|
||||||
|
try {
|
||||||
|
const updatedProjectResponse = await updateProjectAction({
|
||||||
|
projectId: project.id,
|
||||||
|
data: {
|
||||||
|
customHeadScripts: data.customHeadScripts || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (updatedProjectResponse?.data) {
|
||||||
|
toast.success(t("environments.workspace.general.custom_scripts_updated_successfully"));
|
||||||
|
form.reset({ customHeadScripts: updatedProjectResponse.data.customHeadScripts ?? "" });
|
||||||
|
} else {
|
||||||
|
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form className="flex w-full flex-col space-y-4" onSubmit={form.handleSubmit(updateCustomScripts)}>
|
||||||
|
<Alert variant="warning" className="flex items-start gap-2">
|
||||||
|
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<AlertDescription>{t("environments.workspace.general.custom_scripts_warning")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customHeadScripts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="customHeadScripts">
|
||||||
|
{t("environments.workspace.general.custom_scripts_label")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("environments.workspace.general.custom_scripts_description")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
id="customHeadScripts"
|
||||||
|
rows={8}
|
||||||
|
placeholder={t("environments.workspace.general.custom_scripts_placeholder")}
|
||||||
|
className={cn(
|
||||||
|
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
isReadOnly && "bg-slate-50"
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting || !isDirty || isReadOnly}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
{isReadOnly && (
|
||||||
|
<Alert variant="warning" className="mt-4">
|
||||||
|
<AlertDescription>
|
||||||
|
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
|
|||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
|
import { CustomScriptsForm } from "./components/custom-scripts-form";
|
||||||
import { DeleteProject } from "./components/delete-project";
|
import { DeleteProject } from "./components/delete-project";
|
||||||
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
import { EditProjectNameForm } from "./components/edit-project-name-form";
|
||||||
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
|
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
|
||||||
@@ -39,6 +40,13 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
|
|||||||
description={t("environments.workspace.general.recontact_waiting_time_settings_description")}>
|
description={t("environments.workspace.general.recontact_waiting_time_settings_description")}>
|
||||||
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
|
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
{!IS_FORMBRICKS_CLOUD && (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.workspace.general.custom_scripts")}
|
||||||
|
description={t("environments.workspace.general.custom_scripts_card_description")}>
|
||||||
|
<CustomScriptsForm project={project} isReadOnly={!isOwnerOrManager} />
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("environments.workspace.general.delete_workspace")}
|
title={t("environments.workspace.general.delete_workspace")}
|
||||||
description={t("environments.workspace.general.delete_workspace_settings_description")}>
|
description={t("environments.workspace.general.delete_workspace_settings_description")}>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const selectProject = {
|
|||||||
environments: true,
|
environments: true,
|
||||||
styling: true,
|
styling: true,
|
||||||
logo: true,
|
logo: true,
|
||||||
|
customHeadScripts: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (
|
export const updateProject = async (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
EMAIL_VERIFICATION_DISABLED,
|
EMAIL_VERIFICATION_DISABLED,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
|
IS_FORMBRICKS_CLOUD,
|
||||||
IS_TURNSTILE_CONFIGURED,
|
IS_TURNSTILE_CONFIGURED,
|
||||||
OIDC_DISPLAY_NAME,
|
OIDC_DISPLAY_NAME,
|
||||||
OIDC_OAUTH_ENABLED,
|
OIDC_OAUTH_ENABLED,
|
||||||
@@ -57,6 +58,7 @@ export const SignupPage = async () => {
|
|||||||
samlTenant={SAML_TENANT}
|
samlTenant={SAML_TENANT}
|
||||||
samlProduct={SAML_PRODUCT}
|
samlProduct={SAML_PRODUCT}
|
||||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user