Compare commits

..

45 Commits

Author SHA1 Message Date
pandeymangg
0233541287 adds security signup UI in the org settings 2025-12-25 13:23:07 +05:30
Dhruwang Jariwala
adf12f551d fix: Swedish translations (#7032) 2025-12-23 12:02:26 +00:00
Dhruwang Jariwala
3f2bddc358 feat: Russian translations (#7027) 2025-12-23 10:31:09 +00:00
Dhruwang Jariwala
ae6d1ac133 chore: improve wording in email text (Duplicate of #7003) (#7025)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-23 09:56:53 +00:00
Dhruwang Jariwala
7c4569cd50 fix: file upload validation (#7028) 2025-12-23 09:36:45 +00:00
Matti Nannt
7354122447 fix: update V2 API OpenAPI paths to include full prefixes (#6983)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-23 06:29:25 +00:00
Matti Nannt
d54dca2b27 docs: update thanks section with chromatic and sentry logos (#7031) 2025-12-22 16:40:39 +00:00
Anshuman Pandey
acd5cff534 feat: email package for client side email components (#6986) 2025-12-22 14:13:06 +00:00
Matti Nannt
834929e766 feat: configure @formbricks/survey-ui for external publishing (#6991) 2025-12-22 12:39:54 +00:00
Dhruwang Jariwala
09f40ad816 fix: required cta issue (#7022) 2025-12-22 08:35:08 +00:00
Harsh Bhat
689b6491b3 docs: Link vs In app surveys (#7006)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-22 08:13:45 +00:00
Johannes
b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat
392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
Anshuman Pandey
66d9cc8eac chore: adds docs for min browser version support (#7014) 2025-12-19 10:02:01 +00:00
Johannes
befdc078f1 fix: replace isomorphic-dompurify with sanitize-html in server component (#7002) 2025-12-19 07:34:56 +00:00
Dhruwang Jariwala
13b983b3b2 fix: missing question media (#6997)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 07:29:06 +00:00
Harsh Bhat
1e285ebe4e docs: Remove references of delay removal with debug mode (#7009) 2025-12-19 07:03:02 +00:00
Dhruwang Jariwala
a7c4971952 fix: replaced bg-white with survey-bg color in surveys package (#7004)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 06:50:33 +00:00
Dhruwang Jariwala
c8689d91d5 fix: empty button in cta question (#6995) 2025-12-18 21:18:48 +00:00
Dhruwang Jariwala
73a2ff7421 fix: border radius for inputs (#6996) 2025-12-18 20:56:47 +00:00
Dhruwang Jariwala
0c28e89b41 fix: missing required question warning (#6998) 2025-12-18 19:12:47 +00:00
Anshuman Pandey
a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes
7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
Matti Nannt
e71f3f412c feat: Add base path support for Formbricks (#6853) 2025-12-17 17:13:32 +00:00
Anshuman Pandey
07ed926225 fix: updates the patch to fix the next-auth no proxy issue (#6987)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-17 17:11:40 +00:00
Dhruwang Jariwala
15dc83a4eb feat: improved survey UI (#6988)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-17 16:13:28 +01:00
Johannes
3ce07edf43 chore: replacing intercom with chatwoot (#6980)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 16:16:09 +00:00
Johannes
0f34d9cc5f feat: standardize URL prefilling with option ID support and MQB support (#6970)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 10:09:47 +00:00
Matti Nannt
e9f800f017 fix: prepare pnpm in runner stage for airgapped deployments (#6925) 2025-12-15 13:30:55 +00:00
Johannes
ba2070b638 feat: add vars & hidden fields + send to verified email to followups (#6874)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 09:09:43 +00:00
Johannes
75cdb25d27 fix: improve survey response queue robustness to prevent data loss (#6959)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-14 08:18:11 +00:00
Johannes
6bc7db852c feat: Save draft without validation (Duplicate of #6847) (#6966)
Co-authored-by: Mahadeva Peruka <97960828+mahadevaperuka@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 21:52:00 +00:00
Matti Nannt
ffb4eac1a4 chore: upgrade azure-playwright (#6949) 2025-12-12 18:14:21 +00:00
Bhagya Amarasinghe
56da3b5725 chore: remove docker compose version pinning and update Traefik image version to v2.11.31 in docker-compose and documentation (#6967)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:29:26 +01:00
dependabot[bot]
c189af5482 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6971)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-12 11:25:57 +01:00
Johannes
5dbf42fd6a feat: add bulk edit for single-select and multi-select options (#6951)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 06:49:49 +00:00
Anshuman Pandey
42525a86a8 fix: close the survey on formbricks.logout (#6955) 2025-12-12 06:03:35 +00:00
Anshuman Pandey
b96f0e67c5 fix: preserve attribute key casing during CSV contact upload (#6958)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-12 05:22:48 +00:00
Johannes
2d7b99ba26 feat: allow team admins to invite members to their own teams (#6891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 05:01:48 +00:00
Matti Nannt
666a79044f fix: skip instance ID in license check during E2E tests (#6968)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-12 04:05:25 +00:00
Johannes
c3d97c2932 fix: docs links (#6960) 2025-12-10 10:59:25 +00:00
Anshuman Pandey
cc5d630a05 chore: adds docs for min ios and android versions (#6956) 2025-12-09 10:11:00 +00:00
Anshuman Pandey
be38d76ccf fix: removes empty imageUrl and videoUrl keys from elements (#6950) 2025-12-09 09:52:01 +00:00
Joel Ekström Svensson
a8eea306e5 feat: Add Swedish sv-SE translation (#6913)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-08 14:49:44 +00:00
Matti Nannt
4fd53ac115 refactor: centralize instance ID generation (#6952) 2025-12-08 13:42:54 +00:00
366 changed files with 32304 additions and 9492 deletions

View File

@@ -0,0 +1,352 @@
# Create New Question Element
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
## Usage
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
1. **Create the component file** `{question-type}.tsx` with this structure:
```typescript
import * as React from "react";
import { ElementHeader } from "../components/element-header";
import { useTextDirection } from "../hooks/use-text-direction";
import { cn } from "../lib/utils";
interface {QuestionType}Props {
/** Unique identifier for the element container */
elementId: string;
/** The main question or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the input/control group */
inputId: string;
/** Current value */
value?: {ValueType};
/** Callback function called when the value changes */
onChange: (value: {ValueType}) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
// Add question-specific props here
}
function {QuestionType}({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
// ... question-specific props
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", /* add other text content from question */],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
export { {QuestionType} };
export type { {QuestionType}Props };
```
2. **Create the Storybook file** `{question-type}.stories.tsx`:
```typescript
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
// Styling options for the StylingPlayground story
interface StylingOptions {
// Question styling
questionHeadlineFontFamily: string;
questionHeadlineFontSize: string;
questionHeadlineFontWeight: string;
questionHeadlineColor: string;
questionDescriptionFontFamily: string;
questionDescriptionFontWeight: string;
questionDescriptionFontSize: string;
questionDescriptionColor: string;
// Add component-specific styling options here
}
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Elements/{QuestionType}",
component: {QuestionType},
parameters: {
layout: "centered",
docs: {
description: {
component: "A complete {question type} question element...",
},
},
},
tags: ["autodocs"],
argTypes: {
headline: {
control: "text",
description: "The main question text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
value: {
control: "object",
description: "Current value",
table: { category: "State" },
},
required: {
control: "boolean",
description: "Whether the field is required",
table: { category: "Validation" },
},
errorMessage: {
control: "text",
description: "Error message to display",
table: { category: "Validation" },
},
dir: {
control: { type: "select" },
options: ["ltr", "rtl", "auto"],
description: "Text direction for RTL support",
table: { category: "Layout" },
},
disabled: {
control: "boolean",
description: "Whether the controls are disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
// Add question-specific argTypes here
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
const args = context.args as StoryProps;
const {
questionHeadlineFontFamily,
questionHeadlineFontSize,
questionHeadlineFontWeight,
questionHeadlineColor,
questionDescriptionFontFamily,
questionDescriptionFontSize,
questionDescriptionFontWeight,
questionDescriptionColor,
// Extract component-specific styling options
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": questionHeadlineFontFamily,
"--fb-question-headline-font-size": questionHeadlineFontSize,
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
"--fb-question-headline-color": questionHeadlineColor,
"--fb-question-description-font-family": questionDescriptionFontFamily,
"--fb-question-description-font-size": questionDescriptionFontSize,
"--fb-question-description-font-weight": questionDescriptionFontWeight,
"--fb-question-description-color": questionDescriptionColor,
// Add component-specific CSS variables
};
return (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
export const StylingPlayground: Story = {
args: {
headline: "Example question?",
description: "Example description",
// Default styling values
questionHeadlineFontFamily: "system-ui, sans-serif",
questionHeadlineFontSize: "1.125rem",
questionHeadlineFontWeight: "600",
questionHeadlineColor: "#1e293b",
questionDescriptionFontFamily: "system-ui, sans-serif",
questionDescriptionFontSize: "0.875rem",
questionDescriptionFontWeight: "400",
questionDescriptionColor: "#64748b",
// Add component-specific default values
},
argTypes: {
// Question styling argTypes
questionHeadlineFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineColor: {
control: "color",
table: { category: "Question Styling" },
},
questionDescriptionFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionColor: {
control: "color",
table: { category: "Question Styling" },
},
// Add component-specific argTypes
},
decorators: [withCSSVariables],
};
export const Default: Story = {
args: {
headline: "Example question?",
// Add default props
},
};
export const WithDescription: Story = {
args: {
headline: "Example question?",
description: "Example description text",
},
};
export const Required: Story = {
args: {
headline: "Example question?",
required: true,
},
};
export const WithError: Story = {
args: {
headline: "Example question?",
errorMessage: "This field is required",
required: true,
},
};
export const Disabled: Story = {
args: {
headline: "Example question?",
disabled: true,
},
};
export const RTL: Story = {
args: {
headline: "مثال على السؤال؟",
description: "مثال على الوصف",
// Add RTL-specific props
},
};
```
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
```css
/* Component-specific CSS variables */
--fb-{component}-{property}: {default-value};
```
4. **Export from** `packages/survey-ui/src/index.ts`:
```typescript
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
```
## Key Requirements
- ✅ Always use `ElementHeader` component for headline/description
- ✅ Always use `useTextDirection` hook for RTL support
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
- ✅ Always include error message display if applicable
- ✅ Always support disabled state if applicable
- ✅ Always add JSDoc comments to props interface
- ✅ Always create Storybook stories with styling playground
- ✅ Always export types from component file
- ✅ Always add to index.ts exports
## Examples
- `open-text.tsx` - Text input/textarea question (string value)
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
## Checklist
When creating a new question element, verify:
- [ ] Component file created with proper structure
- [ ] Props interface with JSDoc comments for all props
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
- [ ] Uses `useTextDirection` hook for RTL support
- [ ] Handles undefined/null values safely
- [ ] Storybook file created with styling playground
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
- [ ] CSS variables added to `globals.css` if component needs custom styling
- [ ] Exported from `index.ts` with types
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable

View File

@@ -9,8 +9,12 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys
# Please set both for now, we will change this in the future
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=

View File

@@ -13,13 +13,12 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
packages: write
id-token: write
actions: read
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -27,16 +26,34 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook
zip: true

View File

@@ -3,14 +3,10 @@ name: E2E Tests
on:
workflow_call:
secrets:
AZURE_CLIENT_ID:
required: false
AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
@@ -21,7 +17,6 @@ env:
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write
contents: read
actions: read
@@ -114,7 +109,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -124,7 +119,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
@@ -207,32 +202,30 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Set Azure Secret Variables
run: |
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
else
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
- name: Determine Playwright execution mode
shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
pnpm test-e2e:azure
set -euo pipefail
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
else
echo "PW_MODE=local" >> "$GITHUB_ENV"
fi
- name: Run E2E Tests (Playwright Service)
if: env.PW_MODE == 'service'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true
run: pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
if: env.PW_MODE == 'local'
env:
CI: true
run: |

View File

@@ -203,6 +203,14 @@ Here are a few options:
</a>
## Thanks
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
<a id="contact-us"></a>
## 📆 Contact us

View File

@@ -1,8 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
};
export default config;

View File

@@ -1,19 +1,6 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
import { I18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
import "../../../packages/survey-ui/src/styles/globals.css";
const preview: Preview = {
parameters: {
@@ -22,9 +9,23 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
expanded: true,
},
backgrounds: {
default: "light",
},
},
decorators: [withLingodotDev],
decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
};
export default preview;

View File

@@ -11,22 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20"
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"prop-types": "15.8.1",
"storybook": "9.0.15",
"vite": "6.4.1",
"@storybook/addon-docs": "9.0.15"
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,7 +1,15 @@
/** @type {import('tailwindcss').Config} */
import base from "../web/tailwind.config";
import surveyUi from "../../packages/survey-ui/tailwind.config";
export default {
...base,
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
};

View File

@@ -1,16 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
define: {
"process.env": {},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "../web"),
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
},
},
});

View File

@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory
WORKDIR /app
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -134,12 +138,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -225,12 +227,13 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}

View File

@@ -5,6 +5,7 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -47,6 +48,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -62,10 +65,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,6 +1,7 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar

View File

@@ -46,6 +46,7 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -56,6 +57,7 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -183,7 +185,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
callbackUrl: loginUrl,
clearEnvironmentId: true,
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
label: t("common.teams"),
label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -18,7 +19,15 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper user={user} />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<ToasterClient />
{children}
</>

View File

@@ -1,11 +1,9 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);

View File

@@ -1,9 +1,9 @@
import { IntegrationType } from "@prisma/client";
import { createHash } from "node:crypto";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
import { getInstanceInfo } from "@/lib/instance";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -129,15 +129,12 @@ export const sendTelemetryEvents = async () => {
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
*/
const sendTelemetry = async (lastSent: number) => {
// Get the oldest organization to generate a stable, anonymized instance ID.
// Get the instance info (hashed oldest organization ID and creation date).
// Using the oldest org ensures the ID doesn't change over time.
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
const instanceInfo = await getInstanceInfo();
if (!instanceInfo) return; // No organization exists, nothing to report
if (!oldestOrg) return; // No organization exists, nothing to report
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
// Optimize database queries to reduce connection pool usage:
// Instead of 15 parallel queries (which could exhaust the connection pool),
@@ -248,7 +245,7 @@ const sendTelemetry = async (lastSent: number) => {
version: packageJson.version, // Formbricks version for compatibility tracking
},
temporal: {
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
},
};

View File

@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
chatwootWebsiteToken?: string;
userEmail?: string | null;
userName?: string | null;
userId?: string | null;
}
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
export const ChatwootWidget = ({
userEmail,
userName,
userId,
chatwootWebsiteToken,
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
name: userName,
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
if (existingScript) return;
const script = document.createElement("script");
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
script.id = CHATWOOT_SCRIPT_ID;
script.async = true;
script.onload = () => {
(
globalThis as unknown as {
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
}
).chatwootSDK?.run({
websiteToken: chatwootWebsiteToken,
baseUrl: chatwootBaseUrl,
});
};
document.head.appendChild(script);
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
scriptElement?.remove();
userSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,67 +0,0 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { useCallback, useEffect } from "react";
import { TUser } from "@formbricks/types/user";
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
initParams = {
user_id: id,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
};
}
Intercom({
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
if (typeof window !== "undefined" && window.Intercom) {
window.Intercom("shutdown");
}
};
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};

View File

@@ -1,26 +0,0 @@
import { createHmac } from "crypto";
import type { TUser } from "@formbricks/types/user";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};

View File

@@ -17,7 +17,9 @@
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
"es-ES",
"sv-SE",
"ru-RU"
]
},
"version": 1.8

View File

@@ -234,6 +234,7 @@ checksums:
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
@@ -310,9 +311,10 @@ checksums:
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
common/report_survey: 147dd05db52e35f5d1f837460fb720f5
common/request_pricing: 58eb24af4f098632709cb7482b70a1cb
@@ -322,10 +324,10 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
common/role_organization: e7dbf80450ceac1c6c22ba5602ea7e66
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
common/save_as_draft: b1b38812110113627d141db981fb1b12
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
@@ -380,7 +382,8 @@ checksums:
common/team_access: 45c6232c71b760eaa33b932dabab4c1c
common/team_id: 134e32d6f7184577a46b2fd83e85e532
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/teams: a2fbdec69342366a2b6033d119aa279a
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
@@ -441,23 +444,24 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_heading: 80763c6e4585cd57fa58e4d2d82e6500
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
emails/invite_accepted_email_text_par1: b27eadc4779c9fa477103d136a6acab9
emails/invite_accepted_email_text_par2: c77209b510baf0415264fdb5ab8076a8
emails/invite_accepted_email_text: 48d792826ab9a97eed27599c17ec70d5
emails/invite_email_button_label: 02099d40cd11e717c0431fa43e68272c
emails/invite_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_email_text_par1: 70b976a3d4a5509f6d905f9f3f962ada
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_email_heading: d9f9b18e4de575980de3cde3e4ed08bf
emails/invite_email_text: 1499fa615105121a133440929b039a64
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -469,6 +473,7 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -840,7 +845,6 @@ checksums:
environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553
environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad
environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d
environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
@@ -1086,13 +1090,24 @@ checksums:
environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab
environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac
environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f
environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2
environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18
environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12
environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10
environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf
environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/security_updates_description: 17c49b565a7dde28b810f67af2e8db07
environments/settings/teams/security_updates_enroll: edcc8815899ece9209ce981c26c44df3
environments/settings/teams/security_updates_enrolled: 98863ec2d846b7a13ff1ed38ce1038fe
environments/settings/teams/security_updates_enrolled_description: d9c7605767af8f4d7265cba7dfba5f11
environments/settings/teams/security_updates_enrolled_successfully: 3bbb41fac1c04effec3af8ffbd8b72c5
environments/settings/teams/security_updates_enrolling: 15ca7daa32fb57e18a0a6357de26eb4b
environments/settings/teams/security_updates_title: 2f5f5f55bb9a325b5c8228bcad4f2784
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca
environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253
@@ -1176,6 +1191,10 @@ checksums:
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb
environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb
environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815
environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
@@ -1295,11 +1314,13 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
@@ -1422,6 +1443,7 @@ checksums:
environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
@@ -1569,6 +1591,7 @@ checksums:
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
@@ -1892,9 +1915,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044

View File

@@ -176,6 +176,8 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
"ru-RU",
];
// Billing constants
@@ -214,9 +216,9 @@ export const BILLING_LIMITS = {
},
} as const;
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;

View File

@@ -39,11 +39,12 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
INTERCOM_APP_ID: z.string().optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -162,15 +163,16 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

@@ -140,6 +140,8 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
"ru-RU": "Английский (США)",
},
},
{
@@ -156,6 +158,8 @@ export const appLanguages = [
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
"ru-RU": "Немецкий",
},
},
{
@@ -172,6 +176,8 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
"ru-RU": "Португальский (Бразилия)",
},
},
{
@@ -188,6 +194,8 @@ export const appLanguages = [
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
"ru-RU": "Французский",
},
},
{
@@ -199,11 +207,13 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineză (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": "Китайский (традиционный)",
},
},
{
@@ -220,6 +230,8 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
"ru-RU": "Португальский (Португалия)",
},
},
{
@@ -236,6 +248,8 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
"ru-RU": "Румынский",
},
},
{
@@ -252,6 +266,8 @@ export const appLanguages = [
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
"ru-RU": "Японский",
},
},
{
@@ -263,11 +279,13 @@ export const appLanguages = [
"fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineză (Simplificată)",
"ro-RO": "Chineza (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
"ru-RU": "Китайский (упрощенный)",
},
},
{
@@ -279,11 +297,13 @@ export const appLanguages = [
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeză",
"ro-RO": "Olandeza",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
"ru-RU": "Голландский",
},
},
{
@@ -300,6 +320,26 @@ export const appLanguages = [
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
"sv-SE": "Spanska",
"ru-RU": "Испанский",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"de-DE": "Schwedisch",
"pt-BR": "Sueco",
"fr-FR": "Suédois",
"zh-Hant-TW": "瑞典語",
"pt-PT": "Sueco",
"ro-RO": "Suedeză",
"ja-JP": "スウェーデン語",
"zh-Hans-CN": "瑞典语",
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
"ru-RU": "Шведский",
},
},
];

50
apps/web/lib/instance.ts Normal file
View File

@@ -0,0 +1,50 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { createHash } from "node:crypto";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export type TInstanceInfo = {
instanceId: string;
createdAt: Date;
};
/**
* Returns instance info including the anonymized instance ID and creation date.
*
* The instance ID is a SHA-256 hash of the oldest organization's ID, ensuring
* it remains stable over time. Used for telemetry and license checks.
*
* @returns Instance info with hashed ID and creation date, or `null` if no organizations exist
*/
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
try {
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true, createdAt: true },
});
if (!oldestOrg) return null;
return {
instanceId: createHash("sha256").update(oldestOrg.id).digest("hex"),
createdAt: oldestOrg.createdAt,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
/**
* Convenience function that returns just the instance ID.
*
* @returns Hashed instance ID, or `null` if no organizations exist
*/
export const getInstanceId = async (): Promise<string | null> => {
const info = await getInstanceInfo();
return info?.instanceId ?? null;
};

View File

@@ -33,6 +33,7 @@ import {
handleTriggerUpdates,
loadNewSegmentInSurvey,
updateSurvey,
updateSurveyInternal,
} from "./service";
// Mock organization service
@@ -948,3 +949,74 @@ describe("Tests for getSurveysBySegmentId", () => {
});
});
});
describe("updateSurveyDraftAction", () => {
beforeEach(() => {
vi.mocked(getActionClasses).mockResolvedValue([mockActionClass] as TActionClass[]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganizationOutput);
});
describe("Happy Path", () => {
test("should save draft with missing translations", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.survey.update.mockResolvedValue(mockSurveyOutput);
// Create a survey with incomplete i18n/fields
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline or other required fields
},
],
} as unknown as TSurvey;
// Expect success (skipValidation = true)
const result = await updateSurveyInternal(incompleteSurvey, true);
expect(result).toBeDefined();
expect(prisma.survey.update).toHaveBeenCalled();
});
test("should allow draft with invalid images if gating is applied", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.survey.update.mockResolvedValue(mockSurveyOutput);
const surveyWithInvalidImage = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question" },
imageUrl: "http://invalid-image-url.com/image.txt", // Invalid image extension
},
],
} as unknown as TSurvey;
// Expect success (skipValidation = true)
await updateSurveyInternal(surveyWithInvalidImage, true);
expect(prisma.survey.update).toHaveBeenCalled();
});
});
describe("Sad Path", () => {
test("should reject publishing survey with incomplete translations", async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
});
});

View File

@@ -284,8 +284,13 @@ export const getSurveyCount = reactCache(async (environmentId: string): Promise<
}
});
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
export const updateSurveyInternal = async (
updatedSurvey: TSurvey,
skipValidation = false
): Promise<TSurvey> => {
if (!skipValidation) {
validateInputs([updatedSurvey, ZSurvey]);
}
try {
const surveyId = updatedSurvey.id;
@@ -301,10 +306,12 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
checkForInvalidImagesInQuestions(questions);
if (!skipValidation) {
checkForInvalidImagesInQuestions(questions);
}
// Add blocks media validation
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
if (!skipValidation && updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
@@ -368,7 +375,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
@@ -568,6 +575,15 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
}
};
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
return updateSurveyInternal(updatedSurvey);
};
// Draft update without validation
export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
return updateSurveyInternal(updatedSurvey, true);
};
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput

View File

@@ -69,6 +69,12 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
});
test("should format time since in Swedish", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
});
describe("timeSinceDate", () => {

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -93,6 +93,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return fr;
case "nl-NL":
return nl;
case "sv-SE":
return sv;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
@@ -105,6 +107,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return zhCN;
case "es-ES":
return es;
case "ru-RU":
return ru;
}
};

View File

@@ -1,6 +1,7 @@
import * as nextHeaders from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
import { appLanguages } from "@/lib/i18n/utils";
import { findMatchingLocale } from "./locale";
// Mock the Next.js headers function
@@ -84,4 +85,25 @@ describe("locale", () => {
expect(result).toBe(germanLocale);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("Swedish locale (sv-SE) is available and selectable", async () => {
// Verify sv-SE is in AVAILABLE_LOCALES
expect(AVAILABLE_LOCALES).toContain("sv-SE");
// Verify Swedish has a language entry with proper labels
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
expect(swedishLanguage).toBeDefined();
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
// Verify the locale can be matched from Accept-Language header
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("sv-SE,en-US"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe("sv-SE");
expect(nextHeaders.headers).toHaveBeenCalled();
});
});

View File

@@ -61,6 +61,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
expect(convertToEmbedUrl("https://player.vimeo.com/video/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
});
test("converts Loom URL to embed URL", () => {
@@ -70,6 +73,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
expect(convertToEmbedUrl("https://www.loom.com/embed/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
});
test("returns undefined for unsupported URLs", () => {
@@ -109,6 +115,7 @@ describe("extractVimeoId", () => {
test("extracts video ID from Vimeo URLs", () => {
expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://player.vimeo.com/video/123456789")).toBe("123456789");
});
test("returns null for invalid Vimeo URLs", () => {
@@ -121,6 +128,7 @@ describe("extractLoomId", () => {
test("extracts video ID from Loom URLs", () => {
expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/embed/abcdef123456")).toBe("abcdef123456");
});
test("returns null for invalid Loom URLs", async () => {

View File

@@ -26,7 +26,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -74,7 +74,7 @@ export const extractYoutubeId = (url: string): string | null => {
};
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const match = regExp.exec(url);
if (match?.[1]) {
@@ -85,7 +85,7 @@ export const extractVimeoId = (url: string): string | null => {
};
export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
const match = regExp.exec(url);
if (match?.[1]) {

View File

@@ -261,6 +261,7 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
@@ -340,6 +341,7 @@
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
@@ -349,10 +351,10 @@
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
"role_organization": "Rolle (Organisation)",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
"save_as_draft": "Als Entwurf speichern",
"save_changes": "Änderungen speichern",
"saving": "Speichern",
"search": "Suchen",
@@ -407,7 +409,8 @@
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
"invite_accepted_email_text_par1": "Wollte dir nur Bescheid geben, dass",
"invite_accepted_email_text_par2": "deine Einladung angenommen hat. Viel Spaß bei der Zusammenarbeit!",
"invite_accepted_email_text": "Nur zur Info: {inviteeName} hat deine Einladung angenommen. Viel Spaß bei der Zusammenarbeit!",
"invite_email_button_label": "Organisation beitreten",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Dein Kollege",
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Dein Kollege {inviterName} hat dich eingeladen, bei Formbricks mitzumachen. Um die Einladung anzunehmen, klicke bitte auf den Link unten:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"number_variable": "Zahlenvariable",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
"response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Benachrichtigungen für dieses Formular ausschalten",
"survey_response_finished_email_view_more_responses": "Zeige {responseCount} weitere Antworten",
"survey_response_finished_email_view_survey_summary": "Umfragezusammenfassung anzeigen",
"text_variable": "Textvariable",
"verification_email_click_on_this_link": "Du kannst auch auf diesen Link klicken:",
"verification_email_heading": "Fast geschafft!",
"verification_email_hey": "Hey 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Teams verwalten",
"no_teams_found": "Keine Teams gefunden",
"only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.",
"permission": "Berechtigung",
"team_name": "Teamname",
"team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren."
@@ -1167,13 +1171,24 @@
"manage_team": "Team verwalten",
"manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.",
"manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.",
"member": "Mitglied",
"member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.",
"member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.",
"organization_role": "Organisationsrolle",
"owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.",
"please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.",
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"security_updates_description": "Melden Sie sich für unsere Sicherheits-Mailingliste an, um informiert zu bleiben, falls Sicherheitslücken gefunden werden.",
"security_updates_enroll": "Jetzt anmelden",
"security_updates_enrolled": "Angemeldet",
"security_updates_enrolled_description": "Sie sind angemeldet, um Sicherheitsupdates unter {email} zu erhalten.",
"security_updates_enrolled_successfully": "Erfolgreich für Sicherheitsupdates angemeldet!",
"security_updates_enrolling": "Wird angemeldet...",
"security_updates_title": "Sicherheitsupdates",
"select_member": "Mitglied auswählen",
"select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",
@@ -1261,6 +1276,10 @@
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"bulk_edit": "Massenbearbeitung",
"bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.",
"bulk_edit_options": "Optionen massenbearbeiten",
"bulk_edit_options_for": "Optionen massenbearbeiten für {language}",
"button_external": "Externen Link aktivieren",
"button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet",
"button_label": "Beschriftung",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
"follow_ups_include_hidden_fields": "Werte versteckter Felder einbeziehen",
"follow_ups_include_variables": "Variablenwerte einbeziehen",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
"follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
"follow_ups_modal_action_attach_response_data_description": "Fügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"optional": "Optional",
"options": "Optionen",
"options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"untitled_block": "Unbenannter Block",
"update_options": "Optionen aktualisieren",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",
@@ -2030,7 +2053,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read Docs",
"read_docs": "Read docs",
"recipients": "Recipients",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
@@ -349,10 +351,10 @@
"responses": "Responses",
"restart": "Restart",
"role": "Role",
"role_organization": "Role (Organization)",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
"save_as_draft": "Save as draft",
"save_changes": "Save changes",
"saving": "Saving",
"search": "Search",
@@ -407,7 +409,8 @@
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_subject": "You've got a new organization member!",
"invite_accepted_email_text_par1": "Just letting you know that",
"invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"invite_accepted_email_text": "Just letting you know that {inviteeName} accepted your invitation. Have fun collaborating!",
"invite_email_button_label": "Join organization",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Your colleague",
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Your colleague {inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"number_variable": "Number variable",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
"response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"survey_response_finished_email_view_survey_summary": "View survey summary",
"text_variable": "Text variable",
"verification_email_click_on_this_link": "You can also click on this link:",
"verification_email_heading": "Almost there!",
"verification_email_hey": "Hey \uD83D\uDC4B",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Manage teams",
"no_teams_found": "No teams found",
"only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.",
"permission": "Permission",
"team_name": "Team Name",
"team_settings_description": "See which teams can access this project."
@@ -1167,13 +1171,24 @@
"manage_team": "Manage team",
"manage_team_disabled": "Only organization owners, managers and team admins can manage teams.",
"manager_role_description": "Managers can access all projects and add and remove members.",
"member": "Member",
"member_role_description": "Members can work in selected projects.",
"member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.",
"organization_role": "Organization role",
"owner_role_description": "Owners have full control over the organization.",
"please_fill_all_member_fields": "Please fill all the fields to add a new member.",
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
"security_updates_description": "Enroll to our Security Mailing List to stay informed if vulnerabilities are found.",
"security_updates_enroll": "Enroll now",
"security_updates_enrolled": "Enrolled",
"security_updates_enrolled_description": "You're enrolled to receive security updates at {email}.",
"security_updates_enrolled_successfully": "Successfully enrolled for security updates!",
"security_updates_enrolling": "Enrolling...",
"security_updates_title": "Security Updates",
"select_member": "Select member",
"select_project": "Select project",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully.",
"team_deleted_successfully": "Team deleted successfully.",
@@ -1261,6 +1276,10 @@
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"bulk_edit": "Bulk edit",
"bulk_edit_description": "Edit all options below, one per line. Empty lines will be skipped and duplicates removed.",
"bulk_edit_options": "Bulk edit options",
"bulk_edit_options_for": "Bulk edit options for {language}",
"button_external": "Enable External Link",
"button_external_description": "Add a button that opens an external URL in a new tab",
"button_label": "Button Label",
@@ -1365,10 +1384,6 @@
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone",
"export_survey": "Export survey",
"export_survey_error": "Failed to export survey",
"export_survey_loading": "Exporting survey...",
"export_survey_success": "Survey exported successfully",
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1384,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
"follow_ups_include_hidden_fields": "Include hidden field values",
"follow_ups_include_variables": "Include variable values",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
"follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
"follow_ups_modal_action_attach_response_data_description": "Attaches only the questions that were answered in the survey response",
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
@@ -1443,28 +1460,6 @@
"ignore_global_waiting_time": "Ignore project-wide waiting time",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
"initial_value": "Initial value",
@@ -1533,6 +1528,7 @@
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
"optional": "Optional",
"options": "Options",
"options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
@@ -1682,6 +1678,7 @@
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"untitled_block": "Untitled Block",
"update_options": "Update options",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",
@@ -1714,37 +1711,11 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occured while deleting survey",
"export_survey": "Export survey",
"export_survey_error": "Failed to export survey",
"export_survey_loading": "Exporting survey...",
"export_survey_success": "Survey exported successfully",
"filter": {
"complete_and_partial_responses": "Complete and partial responses",
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",
@@ -2080,9 +2051,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
@@ -340,6 +341,7 @@
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
"report_survey": "Reportar encuesta",
"request_pricing": "Solicitar precios",
@@ -349,10 +351,10 @@
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
"role_organization": "Rol (organización)",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
"save_as_draft": "Guardar como borrador",
"save_changes": "Guardar cambios",
"saving": "Guardando",
"search": "Buscar",
@@ -407,7 +409,8 @@
"team_access": "Acceso de equipo",
"team_id": "ID de equipo",
"team_name": "Nombre del equipo",
"teams": "Control de acceso",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_heading": "Hola, {inviterName}",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
"invite_accepted_email_text_par1": "Solo para informarte que",
"invite_accepted_email_text_par2": "ha aceptado tu invitación. ¡Diviértete colaborando!",
"invite_accepted_email_text": "Te informamos que {inviteeName} ha aceptado tu invitación. ¡Que disfrutéis colaborando!",
"invite_email_button_label": "Unirse a la organización",
"invite_email_heading": "Hola",
"invite_email_text_par1": "Tu colega",
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_email_heading": "Hola, {inviteeName}",
"invite_email_text": "Tu compañero {inviterName} te ha invitado a unirte a Formbricks. Para aceptar la invitación, haz clic en el enlace que aparece a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
"number_variable": "Variable numérica",
"password_changed_email_heading": "Contraseña cambiada",
"password_changed_email_text": "Tu contraseña se ha cambiado correctamente.",
"password_reset_notify_email_subject": "Tu contraseña de Formbricks ha sido cambiada",
"privacy_policy": "Política de privacidad",
"reject": "Rechazar",
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
"response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
"response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desactivar notificaciones para este formulario",
"survey_response_finished_email_view_more_responses": "Ver {responseCount} respuestas más",
"survey_response_finished_email_view_survey_summary": "Ver resumen de la encuesta",
"text_variable": "Variable de texto",
"verification_email_click_on_this_link": "También puedes hacer clic en este enlace:",
"verification_email_heading": "¡Ya casi está!",
"verification_email_hey": "Hola 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Gestionar equipos",
"no_teams_found": "No se han encontrado equipos",
"only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.",
"permission": "Permiso",
"team_name": "Nombre del equipo",
"team_settings_description": "Consulta qué equipos pueden acceder a este proyecto."
@@ -1167,13 +1171,24 @@
"manage_team": "Gestionar equipo",
"manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.",
"manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.",
"member": "Miembro",
"member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.",
"member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.",
"organization_role": "Rol en la organización",
"owner_role_description": "Los propietarios tienen control total sobre la organización.",
"please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.",
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"security_updates_description": "Inscríbete en nuestra lista de correo de seguridad para mantenerte informado si se encuentran vulnerabilidades.",
"security_updates_enroll": "Inscribirse ahora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Estás inscrito para recibir actualizaciones de seguridad en {email}.",
"security_updates_enrolled_successfully": "Te has inscrito correctamente para recibir actualizaciones de seguridad.",
"security_updates_enrolling": "Inscribiendo...",
"security_updates_title": "Actualizaciones de seguridad",
"select_member": "Seleccionar miembro",
"select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",
@@ -1261,6 +1276,10 @@
"bold": "Negrita",
"brand_color": "Color de marca",
"brightness": "Brillo",
"bulk_edit": "Edición masiva",
"bulk_edit_description": "Edita todas las opciones a continuación, una por línea. Las líneas vacías se omitirán y los duplicados se eliminarán.",
"bulk_edit_options": "Edición masiva de opciones",
"bulk_edit_options_for": "Edición masiva de opciones para {language}",
"button_external": "Habilitar enlace externo",
"button_external_description": "Añadir un botón que abre una URL externa en una nueva pestaña",
"button_label": "Etiqueta del botón",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Esta tarjeta de finalización se utiliza en seguimientos. Al eliminarla se quitará de todos los seguimientos. ¿Estás seguro de que quieres eliminarla?",
"follow_ups_ending_card_delete_modal_title": "¿Eliminar tarjeta de finalización?",
"follow_ups_hidden_field_error": "El campo oculto se utiliza en un seguimiento. Por favor, elimínalo primero del seguimiento.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variables",
"follow_ups_item_ending_tag": "Finalización(es)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Cualquier respuesta",
"follow_ups_item_send_email_tag": "Enviar correo electrónico",
"follow_ups_modal_action_attach_response_data_description": "Añadir los datos de la respuesta de la encuesta al seguimiento",
"follow_ups_modal_action_attach_response_data_description": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"optional": "Opcional",
"options": "Opciones",
"options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?",
"until_they_submit_a_response": "Preguntar hasta que envíen una respuesta",
"untitled_block": "Bloque sin título",
"update_options": "Actualizar opciones",
"upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones",
"upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior",
"upload": "Subir",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire les documents",
"read_docs": "Lire la documentation",
"recipients": "Destinataires",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Connaître le tarif",
@@ -349,10 +351,10 @@
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
"role_organization": "Rôle (Organisation)",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
"save_as_draft": "Enregistrer comme brouillon",
"save_changes": "Enregistrer les modifications",
"saving": "Sauvegarder",
"search": "Recherche",
@@ -407,7 +409,8 @@
"team_access": "Accès",
"team_id": "Identifiant de l'équipe",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_heading": "Salut {inviterName}",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
"invite_accepted_email_text_par1": "Je te fais savoir que",
"invite_accepted_email_text_par2": "accepté votre invitation. Amusez-vous bien à collaborer !",
"invite_accepted_email_text": "Juste pour te faire savoir que {inviteeName} a accepté ton invitation. Amusez-vous bien à collaborer!",
"invite_email_button_label": "Rejoindre l'organisation",
"invite_email_heading": "Salut",
"invite_email_text_par1": "Votre collègue",
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_email_heading": "Salut {inviteeName}",
"invite_email_text": "Ton collègue {inviterName} t'a invité à le rejoindre sur Formbricks. Pour accepter l'invitation, clique sur le lien ci-dessous:",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"number_variable": "Variable numérique",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
"response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Désactiver les notifications pour ce formulaire",
"survey_response_finished_email_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"survey_response_finished_email_view_survey_summary": "Voir le résumé de l'enquête",
"text_variable": "Variable texte",
"verification_email_click_on_this_link": "Vous pouvez également cliquer sur ce lien :",
"verification_email_heading": "Presque là !",
"verification_email_hey": "Salut 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Gérer les équipes",
"no_teams_found": "Aucune équipe trouvée",
"only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.",
"permission": "Permission",
"team_name": "Nom de l'équipe",
"team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet."
@@ -1167,13 +1171,24 @@
"manage_team": "Gérer l'équipe",
"manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.",
"manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.",
"member": "Membre",
"member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.",
"member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.",
"organization_role": "Rôle dans l'organisation",
"owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.",
"please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.",
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
"security_updates_description": "Inscrivez-vous à notre liste de diffusion sécurité pour être informé si des vulnérabilités sont découvertes.",
"security_updates_enroll": "S'inscrire maintenant",
"security_updates_enrolled": "Inscrit",
"security_updates_enrolled_description": "Vous êtes inscrit pour recevoir les mises à jour de sécurité à {email}.",
"security_updates_enrolled_successfully": "Inscription aux mises à jour de sécurité réussie!",
"security_updates_enrolling": "Inscription en cours...",
"security_updates_title": "Mises à jour de sécurité",
"select_member": "Sélectionner membre",
"select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",
@@ -1261,6 +1276,10 @@
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"bulk_edit": "Modification en masse",
"bulk_edit_description": "Modifiez toutes les options ci-dessous, une par ligne. Les lignes vides seront ignorées et les doublons supprimés.",
"bulk_edit_options": "Modifier les options en masse",
"bulk_edit_options_for": "Modifier les options en masse pour {language}",
"button_external": "Activer le lien externe",
"button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet",
"button_label": "Label du bouton",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Cette carte de fin est utilisée dans les suivis. La supprimer la retirera de tous les suivis. Êtes-vous sûr de vouloir la supprimer ?",
"follow_ups_ending_card_delete_modal_title": "Supprimer la carte de fin ?",
"follow_ups_hidden_field_error": "Le champ caché est utilisé dans un suivi. Veuillez d'abord le supprimer du suivi.",
"follow_ups_include_hidden_fields": "Inclure les valeurs des champs cachés",
"follow_ups_include_variables": "Inclure les valeurs des variables",
"follow_ups_item_ending_tag": "Fin(s)",
"follow_ups_item_issue_detected_tag": "Problème détecté",
"follow_ups_item_response_tag": "Une réponse quelconque",
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
"follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
"follow_ups_modal_action_attach_response_data_description": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"optional": "Optionnel",
"options": "Options",
"options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique: {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
"update_options": "Mettre à jour les options",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
@@ -340,6 +341,7 @@
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
"report_survey": "フォームを報告",
"request_pricing": "料金を問い合わせる",
@@ -349,10 +351,10 @@
"responses": "回答",
"restart": "再開",
"role": "役割",
"role_organization": "役割(組織)",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
"save_as_draft": "下書きとして保存",
"save_changes": "変更を保存",
"saving": "保存中",
"search": "検索",
@@ -407,7 +409,8 @@
"team_access": "チームアクセス",
"team_id": "チームID",
"team_name": "チーム名",
"teams": "アクセス制御",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_heading": "{inviterName}さん",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
"invite_accepted_email_text_par1": "お知らせですが、",
"invite_accepted_email_text_par2": "があなたの招待を承認しました。コラボレーションを楽しんでください!",
"invite_accepted_email_text": "{inviteeName}さんがあなたの招待を承認しました。コラボレーションをお楽しみください!",
"invite_email_button_label": "組織に参加",
"invite_email_heading": "こんにちは",
"invite_email_text_par1": "あなたの同僚の",
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_email_heading": "{inviteeName}さん",
"invite_email_text": "同僚の{inviterName}さんがFormbricksへの参加を招待しています。招待を承認するには、以下のリンクをクリックしてください",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
"number_variable": "数値変数",
"password_changed_email_heading": "パスワードが変更されました",
"password_changed_email_text": "パスワードが正常に変更されました。",
"password_reset_notify_email_subject": "Formbricksのパスワードが変更されました",
"privacy_policy": "プライバシーポリシー",
"reject": "拒否",
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
"response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
"response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "このフォームの通知をオフにする",
"survey_response_finished_email_view_more_responses": "さらに {responseCount} 件の回答を見る",
"survey_response_finished_email_view_survey_summary": "フォームの概要を見る",
"text_variable": "テキスト変数",
"verification_email_click_on_this_link": "このリンクをクリックすることもできます:",
"verification_email_heading": "あと少しです!",
"verification_email_hey": "こんにちは 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "チームを管理",
"no_teams_found": "チームが見つかりません",
"only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。",
"permission": "権限",
"team_name": "チーム名",
"team_settings_description": "このプロジェクトにアクセスできるチームを確認します。"
@@ -1167,13 +1171,24 @@
"manage_team": "チームを管理",
"manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。",
"manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。",
"member": "メンバー",
"member_role_description": "メンバーは選択されたプロジェクトで作業できます。",
"member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。",
"organization_role": "組織の役割",
"owner_role_description": "オーナーは組織を完全に制御できます。",
"please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。",
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
"security_updates_description": "脆弱性が発見された際に通知を受け取るため、セキュリティメーリングリストに登録してください。",
"security_updates_enroll": "今すぐ登録",
"security_updates_enrolled": "登録済み",
"security_updates_enrolled_description": "{email}でセキュリティアップデートを受信するよう登録されています。",
"security_updates_enrolled_successfully": "セキュリティアップデートの登録が完了しました",
"security_updates_enrolling": "登録中...",
"security_updates_title": "セキュリティアップデート",
"select_member": "メンバーを選択",
"select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",
@@ -1261,6 +1276,10 @@
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"bulk_edit": "一括編集",
"bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。",
"bulk_edit_options": "オプションの一括編集",
"bulk_edit_options_for": "{language}のオプションを一括編集",
"button_external": "外部リンクを有効にする",
"button_external_description": "新しいタブで外部URLを開くボタンを追加する",
"button_label": "ボタンのラベル",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "この終了カードはフォローアップで使用されています。これを削除すると、すべてのフォローアップから削除されます。本当に削除しますか?",
"follow_ups_ending_card_delete_modal_title": "終了カードを削除しますか?",
"follow_ups_hidden_field_error": "非表示フィールドはフォローアップで使用されています。まず、フォローアップから削除してください。",
"follow_ups_include_hidden_fields": "非表示フィールドの値を含める",
"follow_ups_include_variables": "変数の値を含める",
"follow_ups_item_ending_tag": "終了",
"follow_ups_item_issue_detected_tag": "問題が検出されました",
"follow_ups_item_response_tag": "任意の回答",
"follow_ups_item_send_email_tag": "メールを送信",
"follow_ups_modal_action_attach_response_data_description": "フォームの回答データをフォローアップに追加する",
"follow_ups_modal_action_attach_response_data_description": "アンケート回答で答えられた質問のみを添付します",
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"optional": "オプション",
"options": "オプション",
"options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"untitled_block": "無題のブロック",
"update_options": "オプションを更新",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
"paragraph_3": "私たちは最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
"welcome_to_formbricks": "Formbricksへようこそ"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
@@ -337,9 +338,10 @@
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
"read_docs": "Lees Documenten",
"read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"report_survey": "Verslag enquête",
"request_pricing": "Vraag prijzen aan",
@@ -349,10 +351,10 @@
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
"role_organization": "Rol (organisatie)",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
"save_as_draft": "Opslaan als concept",
"save_changes": "Wijzigingen opslaan",
"saving": "Besparing",
"search": "Zoekopdracht",
@@ -407,7 +409,8 @@
"team_access": "Teamtoegang",
"team_id": "Team-ID",
"team_name": "Teamnaam",
"teams": "Toegangscontrole",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_heading": "Hé {inviterName}",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
"invite_accepted_email_text_par1": "Laat het je gewoon weten",
"invite_accepted_email_text_par2": "heeft uw uitnodiging geaccepteerd. Veel plezier met samenwerken!",
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
"invite_email_button_label": "Sluit je aan bij de organisatie",
"invite_email_heading": "Hoi",
"invite_email_text_par1": "Jouw collega",
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_email_heading": "Hé {inviteeName}",
"invite_email_text": "Je collega {inviterName} heeft je uitgenodigd om samen te werken bij Formbricks. Klik op onderstaande link om de uitnodiging te accepteren:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
"number_variable": "Numerieke variabele",
"password_changed_email_heading": "Wachtwoord gewijzigd",
"password_changed_email_text": "Uw wachtwoord is succesvol gewijzigd.",
"password_reset_notify_email_subject": "Uw Formbricks-wachtwoord is gewijzigd",
"privacy_policy": "Privacybeleid",
"reject": "Afwijzen",
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
"response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
"response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Schakel meldingen voor dit formulier uit",
"survey_response_finished_email_view_more_responses": "Bekijk nog {responseCount} reacties",
"survey_response_finished_email_view_survey_summary": "Bekijk de samenvatting van het onderzoek",
"text_variable": "Tekstvariabele",
"verification_email_click_on_this_link": "U kunt ook op deze link klikken:",
"verification_email_heading": "Bijna daar!",
"verification_email_hey": "Hé 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Beheer teams",
"no_teams_found": "Geen teams gevonden",
"only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.",
"permission": "Toestemming",
"team_name": "Teamnaam",
"team_settings_description": "Bekijk welke teams toegang hebben tot dit project."
@@ -1167,13 +1171,24 @@
"manage_team": "Beheer team",
"manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.",
"manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.",
"member": "Lid",
"member_role_description": "Leden kunnen in geselecteerde projecten werken.",
"member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.",
"organization_role": "Organisatierol",
"owner_role_description": "Eigenaars hebben volledige controle over de organisatie.",
"please_fill_all_member_fields": "Vul alle velden in om een nieuw lid toe te voegen.",
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"security_updates_description": "Schrijf je in voor onze beveiligingsmailinglijst om op de hoogte te blijven als er kwetsbaarheden worden gevonden.",
"security_updates_enroll": "Nu inschrijven",
"security_updates_enrolled": "Ingeschreven",
"security_updates_enrolled_description": "Je bent ingeschreven om beveiligingsupdates te ontvangen op {email}.",
"security_updates_enrolled_successfully": "Succesvol ingeschreven voor beveiligingsupdates!",
"security_updates_enrolling": "Bezig met inschrijven...",
"security_updates_title": "Beveiligingsupdates",
"select_member": "Selecteer lid",
"select_project": "Selecteer project",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",
@@ -1261,6 +1276,10 @@
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
"brightness": "Helderheid",
"bulk_edit": "Bulkbewerking",
"bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.",
"bulk_edit_options": "Opties bulkbewerken",
"bulk_edit_options_for": "Opties bulkbewerken voor {language}",
"button_external": "Externe link inschakelen",
"button_external_description": "Voeg een knop toe die een externe URL in een nieuw tabblad opent",
"button_label": "Knoplabel",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Deze eindkaart wordt gebruikt bij vervolgacties. Als u het verwijdert, wordt het uit alle vervolgacties verwijderd. Weet je zeker dat je het wilt verwijderen?",
"follow_ups_ending_card_delete_modal_title": "Eindkaart verwijderen?",
"follow_ups_hidden_field_error": "Verborgen veld wordt gebruikt in een follow-up. Verwijder het eerst uit de follow-up.",
"follow_ups_include_hidden_fields": "Inclusief waarden van verborgen velden",
"follow_ups_include_variables": "Inclusief variabele waarden",
"follow_ups_item_ending_tag": "Einde(n)",
"follow_ups_item_issue_detected_tag": "Probleem gedetecteerd",
"follow_ups_item_response_tag": "Enige reactie",
"follow_ups_item_send_email_tag": "E-mail verzenden",
"follow_ups_modal_action_attach_response_data_description": "Voeg de gegevens van de enquêtereactie toe aan de follow-up",
"follow_ups_modal_action_attach_response_data_description": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"optional": "Optioneel",
"options": "Opties",
"options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?",
"until_they_submit_a_response": "Vraag totdat ze een reactie indienen",
"untitled_block": "Naamloos blok",
"update_options": "Opties bijwerken",
"upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies",
"upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan",
"upload": "Uploaden",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
@@ -337,9 +338,10 @@
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler Documentação",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
@@ -349,10 +351,10 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
"role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
"save_as_draft": "Salvar como rascunho",
"save_changes": "Salvar alterações",
"saving": "Salvando",
"search": "Buscar",
@@ -407,7 +409,8 @@
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_heading": "Olá, {inviterName}",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
"invite_accepted_email_text_par1": "Só pra te avisar que",
"invite_accepted_email_text_par2": "aceitou seu convite. Divirta-se colaborando!",
"invite_accepted_email_text": "Só para você saber que {inviteeName} aceitou seu convite. Divirta-se colaborando!",
"invite_email_button_label": "Entrar na organização",
"invite_email_heading": "E aí",
"invite_email_text_par1": "Seu colega",
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_email_heading": "Olá, {inviteeName}",
"invite_email_text": "Seu colega {inviterName} convidou você para se juntar a ele no Formbricks. Para aceitar o convite, clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo da pesquisa",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Você também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Oi 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Gerenciar Equipes",
"no_teams_found": "Nenhuma equipe encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.",
"permission": "Permissão",
"team_name": "Nome da equipe",
"team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso."
@@ -1167,13 +1171,24 @@
"manage_team": "Gerenciar equipe",
"manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.",
"manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controle total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"security_updates_description": "Inscreva-se na nossa lista de e-mails de segurança para ser informado caso vulnerabilidades sejam encontradas.",
"security_updates_enroll": "Inscrever-se agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Você está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "Inscrevendo...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",
@@ -1261,6 +1276,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"bulk_edit": "Edição em massa",
"bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicatas removidas.",
"bulk_edit_options": "Editar opções em massa",
"bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Habilitar link externo",
"button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba",
"button_label": "Rótulo do Botão",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
@@ -337,9 +338,10 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler Documentos",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
@@ -349,10 +351,10 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
"role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
"save_as_draft": "Guardar como rascunho",
"save_changes": "Guardar alterações",
"saving": "Guardando",
"search": "Procurar",
@@ -407,7 +409,8 @@
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_heading": "Olá {inviterName}",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
"invite_accepted_email_text_par1": "Só para te informar que",
"invite_accepted_email_text_par2": "aceitou o seu convite. Divirta-se a colaborar!",
"invite_accepted_email_text": "Só para informar que {inviteeName} aceitou o teu convite. Divirtam-se a colaborar!",
"invite_email_button_label": "Junte-se à organização",
"invite_email_heading": "Olá",
"invite_email_text_par1": "O seu colega",
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_email_heading": "Olá {inviteeName}",
"invite_email_text": "O teu colega {inviterName} convidou-te para te juntares a ele no Formbricks. Para aceitar o convite, clica na ligação abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo do inquérito",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Olá 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Gerir equipas",
"no_teams_found": "Nenhuma equipa encontrada",
"only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.",
"permission": "Permissão",
"team_name": "Nome da Equipa",
"team_settings_description": "Veja quais equipas podem aceder a este projeto."
@@ -1167,13 +1171,24 @@
"manage_team": "Gerir equipa",
"manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.",
"manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.",
"member": "Membro",
"member_role_description": "Os membros podem trabalhar em projetos selecionados.",
"member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.",
"organization_role": "Função na organização",
"owner_role_description": "Os proprietários têm controlo total sobre a organização.",
"please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.",
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
"security_updates_description": "Inscreva-se na nossa lista de correio de segurança para se manter informado caso sejam encontradas vulnerabilidades.",
"security_updates_enroll": "Inscrever agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "A inscrever...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",
@@ -1261,6 +1276,10 @@
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"bulk_edit": "Edição em massa",
"bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicados removidos.",
"bulk_edit_options": "Editar opções em massa",
"bulk_edit_options_for": "Editar opções em massa para {language}",
"button_external": "Ativar link externo",
"button_external_description": "Adicionar um botão que abre um URL externo num novo separador",
"button_label": "Rótulo do botão",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Este cartão de encerramento é utilizado em seguimentos. Eliminá-lo irá removê-lo de todos os seguimentos. Tem a certeza de que deseja eliminá-lo?",
"follow_ups_ending_card_delete_modal_title": "Eliminar cartão de encerramento?",
"follow_ups_hidden_field_error": "O campo oculto é usado num seguimento. Por favor, remova-o do seguimento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Encerramento(s)",
"follow_ups_item_issue_detected_tag": "Problema detetado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar email",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"untitled_block": "Bloco sem título",
"update_options": "Atualizar opções",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
@@ -340,6 +341,7 @@
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
@@ -349,10 +351,10 @@
"responses": "Răspunsuri",
"restart": "Repornește",
"role": "Rolul",
"role_organization": "Rol (Organizație)",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
"save_as_draft": "Salvați ca schiță",
"save_changes": "Salvează modificările",
"saving": "Salvare",
"search": "Căutare",
@@ -407,7 +409,8 @@
"team_access": "Acces echipă",
"team_id": "ID echipă",
"team_name": "Nume echipă",
"teams": "Control acces",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_heading": "Salut, {inviterName}",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
"invite_accepted_email_text_par1": "Doar te anunț că",
"invite_accepted_email_text_par2": "a acceptat invitația ta. Distracție plăcută colaborând!",
"invite_accepted_email_text": "Vrem doar să te anunțăm {inviteeName} a acceptat invitația ta. Spor la colaborare!",
"invite_email_button_label": "Alătură-te organizației",
"invite_email_heading": "Hei",
"invite_email_text_par1": "Colegul tău",
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_email_heading": "Salut, {inviteeName}",
"invite_email_text": "Colegul tău, {inviterName}, te-a invitat să i te alături pe Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
"number_variable": "Variabilă numerică",
"password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de confidențialitate",
"reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
"response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
"response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular",
"survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri",
"survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului",
"text_variable": "Variabilă text",
"verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:",
"verification_email_heading": "Aproape gata!",
"verification_email_hey": "Salut 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "Gestionați echipele",
"no_teams_found": "Nicio echipă găsită",
"only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.",
"permission": "Permisiune",
"team_name": "Nume echipă",
"team_settings_description": "Vezi care echipe pot accesa acest proiect."
@@ -1167,13 +1171,24 @@
"manage_team": "Gestionați echipa",
"manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.",
"manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.",
"member": "Membru",
"member_role_description": "Membrii pot lucra în proiectele selectate.",
"member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.",
"organization_role": "Rol în organizație",
"owner_role_description": "Proprietarii au control total asupra organizației.",
"please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.",
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
"security_updates_description": "Înscrie-te la lista noastră de e-mailuri de securitate pentru a fi informat dacă sunt descoperite vulnerabilități.",
"security_updates_enroll": "Înscrie-te acum",
"security_updates_enrolled": "Înscris",
"security_updates_enrolled_description": "Ești înscris pentru a primi actualizări de securitate la {email}.",
"security_updates_enrolled_successfully": "Înscriere reușită pentru actualizările de securitate!",
"security_updates_enrolling": "Se înscrie...",
"security_updates_title": "Actualizări de securitate",
"select_member": "Selectează membrul",
"select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",
@@ -1261,6 +1276,10 @@
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"bulk_edit": "Editare în bloc",
"bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.",
"bulk_edit_options": "Opțiuni de editare în bloc",
"bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}",
"button_external": "Activează link extern",
"button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă",
"button_label": "Etichetă buton",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
"follow_ups_include_hidden_fields": "Include valorile câmpurilor ascunse",
"follow_ups_include_variables": "Include valorile variabilelor",
"follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email",
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
"follow_ups_modal_action_attach_response_data_description": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"optional": "Opțional",
"options": "Opțiuni",
"options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"untitled_block": "Bloc fără titlu",
"update_options": "Actualizați opțiunile",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {

2960
apps/web/locales/ru-RU.json Normal file

File diff suppressed because it is too large Load Diff

2960
apps/web/locales/sv-SE.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
@@ -337,9 +338,10 @@
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读 文档",
"read_docs": "阅读文档",
"recipients": "收件人",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
"report_survey": "报告调查",
"request_pricing": "请求 定价",
@@ -349,10 +351,10 @@
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
"role_organization": "角色 (组织)",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
"save_as_draft": "保存为草稿",
"save_changes": "保存 更改",
"saving": "保存",
"search": "搜索",
@@ -407,7 +409,8 @@
"team_access": "团队 访问",
"team_id": "团队 ID",
"team_name": "团队 名称",
"teams": "访问控制",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "",
"invite_accepted_email_heading": "你好,{inviterName}",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
"invite_accepted_email_text_par1": "只是 告诉 你",
"invite_accepted_email_text_par2": "接受了 你的 邀请。 合作 愉快!",
"invite_accepted_email_text": "{inviteeName} 已接受了你的邀请。祝你们合作愉快!",
"invite_email_button_label": "加入 组织",
"invite_email_heading": "",
"invite_email_text_par1": "您的 同事",
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_email_heading": "你好,{inviteeName}",
"invite_email_text": "你的同事 {inviterName} 邀请你加入 Formbricks。要接受邀请请点击下方链接",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 ",
"number_variable": "数字变量",
"password_changed_email_heading": "密码 已更改",
"password_changed_email_text": "您的 密码已成功更改",
"password_reset_notify_email_subject": "您的 Formbricks 密码已更改",
"privacy_policy": "隐私政策",
"reject": "拒绝",
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
"response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "关闭 此表单 的通知",
"survey_response_finished_email_view_more_responses": "查看 {responseCount} 更多 响应",
"survey_response_finished_email_view_survey_summary": "查看 问卷 摘要",
"text_variable": "文本变量",
"verification_email_click_on_this_link": "您 也 可以 点击 此 链接:",
"verification_email_heading": "马上就好!",
"verification_email_hey": "嗨 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "管理 团队",
"no_teams_found": "未找到 团队",
"only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。",
"permission": "权限",
"team_name": "团队名称",
"team_settings_description": "查看 哪些 团队 可以 访问 该 项目。"
@@ -1167,13 +1171,24 @@
"manage_team": "管理团队",
"manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。",
"manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。",
"member": "成员",
"member_role_description": "成员 可以 在 选定 项目 中 工作。",
"member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。",
"organization_role": "组织角色",
"owner_role_description": "所有者拥有对组织的完全控制权。",
"please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。",
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
"security_updates_description": "加入我们的安全邮件列表,及时了解发现的安全漏洞信息。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,相关信息将发送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "选择成员",
"select_project": "选择项目",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",
@@ -1261,6 +1276,10 @@
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"bulk_edit": "批量编辑",
"bulk_edit_description": "编辑以下所有选项,每行一个。空行将被跳过,重复项将被移除。",
"bulk_edit_options": "批量编辑选项",
"bulk_edit_options_for": "为 {language} 批量编辑选项",
"button_external": "启用外部链接",
"button_external_description": "添加一个按钮在新标签页中打开外部URL",
"button_label": "按钮标签",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "此结束卡片 用于 后续跟踪. 删除 它 将会 从 所有 后续跟踪 中 移除. 确定 要 删除 它 吗?",
"follow_ups_ending_card_delete_modal_title": "删除 结尾卡片?",
"follow_ups_hidden_field_error": "隐藏 字段 用于 后续 。请 先 从 后续 中 移除 它 。",
"follow_ups_include_hidden_fields": "包括隐藏字段值",
"follow_ups_include_variables": "包括变量值",
"follow_ups_item_ending_tag": "结尾",
"follow_ups_item_issue_detected_tag": "问题 检测",
"follow_ups_item_response_tag": "任何 响应",
"follow_ups_item_send_email_tag": "发送 邮件",
"follow_ups_modal_action_attach_response_data_description": "添加 调查 响应 数据 到 跟进",
"follow_ups_modal_action_attach_response_data_description": "仅附加调查响应中已回答的问题",
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"optional": "可选",
"options": "选项",
"options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"untitled_block": "未命名区块",
"update_options": "更新选项",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
"paragraph_1": "Formbricks 是一体验管理套件, 基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_1": "Formbricks 是一体验管理套件,基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {

View File

@@ -261,6 +261,7 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
@@ -340,6 +341,7 @@
"read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
@@ -349,10 +351,10 @@
"responses": "回應",
"restart": "重新開始",
"role": "角色",
"role_organization": "角色(組織)",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
"save_as_draft": "儲存為草稿",
"save_changes": "儲存變更",
"saving": "儲存",
"search": "搜尋",
@@ -407,7 +409,8 @@
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
@@ -470,23 +473,24 @@
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_heading": "嗨{inviterName}",
"invite_accepted_email_subject": "您有一位新的組織成員!",
"invite_accepted_email_text_par1": "通知您,",
"invite_accepted_email_text_par2": "接受了您的邀請。合作愉快!",
"invite_accepted_email_text": "通知你,{inviteeName} 已經接受了你的邀請。祝你們合作愉快!",
"invite_email_button_label": "加入組織",
"invite_email_heading": "嗨",
"invite_email_text_par1": "的同事",
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請請點擊以下連結",
"invite_email_heading": "嗨{inviteeName}",
"invite_email_text": "的同事 {inviterName} 邀請你加入他們在 Formbricks。請點擊下方連結以接受邀請",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"number_variable": "數字變數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
"response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
@@ -498,6 +502,7 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "關閉此表單的通知",
"survey_response_finished_email_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"survey_response_finished_email_view_survey_summary": "檢視問卷摘要",
"text_variable": "文字變數",
"verification_email_click_on_this_link": "您也可以點擊此連結:",
"verification_email_heading": "快完成了!",
"verification_email_hey": "嗨 👋",
@@ -903,7 +908,6 @@
"teams": {
"manage_teams": "管理團隊",
"no_teams_found": "找不到團隊",
"only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。",
"permission": "權限",
"team_name": "團隊名稱",
"team_settings_description": "查看哪些團隊可以存取此專案。"
@@ -1167,13 +1171,24 @@
"manage_team": "管理團隊",
"manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。",
"manager_role_description": "管理員可以存取所有專案,並新增和移除成員。",
"member": "成員",
"member_role_description": "成員可以在選定的專案中工作。",
"member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。",
"organization_role": "組織角色",
"owner_role_description": "擁有者對組織具有完全控制權。",
"please_fill_all_member_fields": "請填寫所有欄位以新增新成員。",
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
"security_updates_description": "加入我們的安全郵件名單,隨時掌握漏洞相關資訊。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,將會寄送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "選擇成員",
"select_project": "選擇專案",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",
@@ -1261,6 +1276,10 @@
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"bulk_edit": "批次編輯",
"bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。",
"bulk_edit_options": "批次編輯選項",
"bulk_edit_options_for": "為 {language} 批次編輯選項",
"button_external": "啟用外部連結",
"button_external_description": "新增一個按鈕,在新分頁中開啟外部網址",
"button_label": "按鈕標籤",
@@ -1380,11 +1399,13 @@
"follow_ups_ending_card_delete_modal_text": "此結尾卡片用於後續追蹤中。刪除它將會從所有後續追蹤中移除。您確定要刪除它嗎?",
"follow_ups_ending_card_delete_modal_title": "刪除結尾卡片?",
"follow_ups_hidden_field_error": "隱藏欄位在後續追蹤中使用。請先從後續追蹤中移除。",
"follow_ups_include_hidden_fields": "包含隱藏欄位的值",
"follow_ups_include_variables": "包含變數的值",
"follow_ups_item_ending_tag": "結尾",
"follow_ups_item_issue_detected_tag": "偵測到問題",
"follow_ups_item_response_tag": "任何回應",
"follow_ups_item_send_email_tag": "發送電子郵件",
"follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
"follow_ups_modal_action_attach_response_data_description": "僅附加在調查回應中回答過的問題",
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
@@ -1507,6 +1528,7 @@
"option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"optional": "選填",
"options": "選項",
"options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
@@ -1656,6 +1678,7 @@
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"untitled_block": "未命名區塊",
"update_options": "更新選項",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",
@@ -2028,9 +2051,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
"paragraph_1": "Formbricks 是一套體驗管理套件,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_1": "Formbricks 是一套體驗管理工具,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
"paragraph_3": "我們致力於最高程度的資料隱私。自託管<b>完全掌控您的資料</b>。",
"paragraph_3": "我們致力於最高等級的資料隱私。自託管,讓您<b>完全掌控您的資料</b>。",
"welcome_to_formbricks": "歡迎使用 Formbricks"
},
"invite": {

View File

@@ -9,7 +9,6 @@ import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
@@ -59,13 +58,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
};
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
servers: managementServer,
"/management/contact-attribute-keys": {
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
servers: managementServer,
"/management/contact-attribute-keys/{id}": {
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,

View File

@@ -1,6 +0,0 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];

View File

@@ -1,6 +1,5 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteResponseEndpoint,
getResponseEndpoint,
@@ -57,13 +56,11 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
};
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
servers: managementServer,
"/management/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
servers: managementServer,
"/management/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,

View File

@@ -1,10 +1,8 @@
import { ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
servers: managementServer,
"/management/surveys/{surveyId}/contact-links/segments/{segmentId}": {
get: getContactLinksBySegmentEndpoint,
},
};

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
@@ -52,19 +51,16 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
};
export const surveyPaths: ZodOpenApiPathsObject = {
// "/surveys": {
// servers: managementServer,
// "/management/surveys": {
// get: getSurveysEndpoint,
// post: createSurveyEndpoint,
// },
// "/surveys/{id}": {
// servers: managementServer,
// "/management/surveys/{id}": {
// get: getSurveyEndpoint,
// put: updateSurveyEndpoint,
// delete: deleteSurveyEndpoint,
// },
"/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
servers: managementServer,
"/management/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
get: getPersonalizedSurveyLink,
},
};

View File

@@ -1,6 +1,5 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteWebhookEndpoint,
getWebhookEndpoint,
@@ -56,13 +55,11 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
};
export const webhookPaths: ZodOpenApiPathsObject = {
"/webhooks": {
servers: managementServer,
"/management/webhooks": {
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{id}": {
servers: managementServer,
"/management/webhooks/{id}": {
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,

View File

@@ -7,7 +7,6 @@ import {
ZProjectTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -119,8 +118,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
};
export const projectTeamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/project-teams": {
servers: organizationServer,
"/organizations/{organizationId}/project-teams": {
get: getProjectTeamsEndpoint,
post: createProjectTeamEndpoint,
put: updateProjectTeamEndpoint,

View File

@@ -11,7 +11,6 @@ import {
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -69,13 +68,11 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
};
export const teamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/teams": {
servers: organizationServer,
"/organizations/{organizationId}/teams": {
get: getTeamsEndpoint,
post: createTeamEndpoint,
},
"/{organizationId}/teams/{id}": {
servers: organizationServer,
"/organizations/{organizationId}/teams/{id}": {
get: getTeamEndpoint,
put: updateTeamEndpoint,
delete: deleteTeamEndpoint,

View File

@@ -7,7 +7,6 @@ import {
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getUsersEndpoint: ZodOpenApiOperationObject = {
@@ -96,8 +95,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
};
export const userPaths: ZodOpenApiPathsObject = {
"/{organizationId}/users": {
servers: organizationServer,
"/organizations/{organizationId}/users": {
get: getUsersEndpoint,
post: createUserEndpoint,
patch: updateUserEndpoint,

View File

@@ -1,6 +0,0 @@
export const organizationServer = [
{
url: `https://app.formbricks.com/api/v2/organizations`,
description: "Formbricks Organizations API",
},
];

View File

@@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { createTeamMembership } from "../team";
import { createTeamMembership, getTeamProjectIds } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -31,6 +31,7 @@ const setupMocks = () => {
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
@@ -55,7 +56,7 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
@@ -90,7 +91,7 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
@@ -110,11 +111,68 @@ describe("Team Management", () => {
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
describe("when team does not exist", () => {
test("skips membership creation and continues to next team", async () => {
const inviteWithMultipleTeams: CreateMembershipInvite = {
...MOCK_INVITE,
teamIds: ["non-existent-team", MOCK_IDS.teamId],
};
vi.mocked(prisma.team.findUnique)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
});
});
});
});
describe("getTeamProjectIds", () => {
test("returns team with projectTeams when team exists", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toEqual(MOCK_TEAM);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
});
test("returns null when team does not exist", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toBeNull();
});
});
});

View File

@@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
if (!team) {
logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
continue;
}
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
}
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
@@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
};
export const getTeamProjectIds = reactCache(
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => {
async (
teamId: string,
organizationId: string
): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
@@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache(
});
if (!team) {
throw new Error("Team not found");
return null;
}
return team;

View File

@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export const SignIn = ({ token }) => {
export const SignIn = ({ token, webAppUrl }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
callbackUrl: `/`,
callbackUrl: webAppUrl,
});
}
}, [token]);
}, [token, webAppUrl]);
return <></>;
};

View File

@@ -1,3 +1,4 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -9,7 +10,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} />
<SignIn token={token} webAppUrl={WEBAPP_URL} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>

View File

@@ -1,6 +1,5 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
const bulkContactEndpoint: ZodOpenApiOperationObject = {
@@ -111,8 +110,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
};
export const bulkContactPaths: ZodOpenApiPathsObject = {
"/contacts/bulk": {
servers: managementServer,
"/management/contacts/bulk": {
put: bulkContactEndpoint,
},
};

View File

@@ -1,5 +1,4 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactCreateRequest, ZContactResponse } from "@/modules/ee/contacts/types/contact";
@@ -54,8 +53,7 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
"/management/contacts": {
post: createContactEndpoint,
},
};

View File

@@ -458,21 +458,15 @@ describe("Contacts Lib", () => {
attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([{ key: "email", id: "key-1" } as any])
.mockResolvedValueOnce([
{ key: "userId", id: "key-2" },
{ key: "email", id: "key-1" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap);
@@ -489,25 +483,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap);
@@ -525,25 +509,15 @@ describe("Contacts Lib", () => {
],
};
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([existingContact as any])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any)
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
.mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any);
@@ -582,23 +556,16 @@ describe("Contacts Lib", () => {
test("creates missing attribute keys", async () => {
const attributeMap = { email: "email", userId: "userId" };
vi.mocked(prisma.contact.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
] as any);
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "key-1" },
{ key: "userId", id: "key-2" },
{ key: "name", id: "key-3" },
] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "new-1",
environmentId: mockEnvironmentId,

View File

@@ -200,6 +200,50 @@ export const deleteContact = async (contactId: string): Promise<TContact | null>
}
};
// Shared include clause for contact queries
const contactAttributesInclude = {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
} satisfies Prisma.ContactInclude;
// Helper to create attribute objects for Prisma create operations
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
// Helper to handle userId conflicts when updating/overwriting contacts
const resolveUserIdConflict = (
mappedRecord: Record<string, string>,
existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
existingUserIds: { value: string; contactId: string }[]
): Record<string, string> => {
const existingUserId = existingUserIds.find(
(attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id
);
if (!existingUserId) {
return { ...mappedRecord };
}
const { userId: _userId, ...rest } = mappedRecord;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
return {
...rest,
...(existingContactUserId && { userId: existingContactUserId }),
};
};
export const createContactsFromCSV = async (
csvData: Record<string, string>[],
environmentId: string,
@@ -287,22 +331,36 @@ export const createContactsFromCSV = async (
});
const attributeKeyMap = new Map<string, string>();
// Map from lowercase key to actual DB key (for case-insensitive lookup)
const lowercaseToActualKeyMap = new Map<string, string>();
existingAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
// Identify missing attribute keys (normalize keys to lowercase)
// Collect all unique CSV keys
const csvKeys = new Set<string>();
csvData.forEach((record) => {
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
Object.keys(record).forEach((key) => csvKeys.add(key));
});
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
// Identify missing attribute keys (case-insensitive check)
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
// Create missing attribute keys
// Create missing attribute keys (use original CSV casing for new keys)
if (missingKeys.length > 0) {
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
missingKeys.forEach((key) => {
const lowerKey = key.toLowerCase();
if (!uniqueMissingKeys.has(lowerKey)) {
uniqueMissingKeys.set(lowerKey, key);
}
});
await prisma.contactAttributeKey.createMany({
data: missingKeys.map((key) => ({
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
environmentId,
@@ -310,10 +368,10 @@ export const createContactsFromCSV = async (
skipDuplicates: true,
});
// Fetch and update the attributeKeyMap with new keys
// Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
key: { in: missingKeys },
key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
@@ -321,6 +379,7 @@ export const createContactsFromCSV = async (
newAttributeKeys.forEach((attrKey) => {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
});
}
@@ -328,18 +387,23 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
// Normalize record keys to lowercase
const normalizedRecord: Record<string, string> = {};
// Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing)
const mappedRecord: Record<string, string> = {};
Object.entries(record).forEach(([key, value]) => {
normalizedRecord[key.toLowerCase()] = value;
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
if (!actualKey) {
// This should never happen since we create missing keys above
throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
}
mappedRecord[actualKey] = value;
});
// Skip records without email
if (!normalizedRecord.email) {
if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
const existingContact = emailToContactMap.get(normalizedRecord.email);
const existingContact = emailToContactMap.get(mappedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -348,25 +412,7 @@ export const createContactsFromCSV = async (
return null;
case "update": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
@@ -383,7 +429,7 @@ export const createContactsFromCSV = async (
}));
// Update contact with upserted attributes
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
@@ -391,98 +437,40 @@ export const createContactsFromCSV = async (
upsert: attributesToUpsert,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
case "overwrite": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
recordToProcess = {
...rest,
...(existingContactUserId && {
userId: existingContactUserId,
}),
};
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
// Overwrite by deleting existing attributes and creating new ones
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const updatedContact = prisma.contact.update({
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(recordToProcess, environmentId),
},
},
include: contactAttributesInclude,
});
return updatedContact;
}
}
} else {
// Create new contact
const newAttributes = Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
const newContact = prisma.contact.create({
// Create new contact - use mappedRecord with proper DB key casing
return prisma.contact.create({
data: {
environmentId,
attributes: {
create: newAttributes,
},
},
include: {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
create: createAttributeConnections(mappedRecord, environmentId),
},
},
include: contactAttributesInclude,
});
return newContact;
}
});

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
import { prisma } from "@formbricks/database";
import { getInstanceId, getInstanceInfo } from "@/lib/instance";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
@@ -55,6 +56,7 @@ vi.mock("@formbricks/database", () => ({
},
organization: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}));
@@ -70,6 +72,11 @@ vi.mock("@formbricks/logger", () => ({
logger: mockLogger,
}));
vi.mock("@/lib/instance", () => ({
getInstanceId: vi.fn(),
getInstanceInfo: vi.fn(),
}));
// Mock constants as they are used in the original license.ts indirectly
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -102,6 +109,15 @@ describe("License Core Logic", () => {
mockCache.withCache.mockImplementation(async (fn) => await fn());
vi.mocked(prisma.response.count).mockResolvedValue(100);
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "test-org-id",
createdAt: new Date("2024-01-01"),
} as any);
vi.mocked(getInstanceId).mockResolvedValue("test-hashed-instance-id");
vi.mocked(getInstanceInfo).mockResolvedValue({
instanceId: "test-hashed-instance-id",
createdAt: new Date("2024-01-01"),
});
vi.clearAllMocks();
// Mock window to be undefined for server-side tests
vi.stubGlobal("window", undefined);

View File

@@ -7,8 +7,10 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { E2E_TESTING } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
@@ -260,14 +262,23 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
const [instanceId, responseCount] = await Promise.all([
// Skip instance ID during E2E tests to avoid license key conflicts
// as the instance ID changes with each test run
E2E_TESTING ? null : getInstanceId(),
prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
},
},
});
}),
]);
// No organization exists, cannot perform license check
// (skip this check during E2E tests as we intentionally use null)
if (!E2E_TESTING && !instanceId) return null;
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
@@ -275,11 +286,17 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
const payload: Record<string, unknown> = {
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
};
if (instanceId) {
payload.instanceId = instanceId;
}
const res = await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,

View File

@@ -60,7 +60,7 @@ export function AddMemberRole({
name="role"
render={({ field: { onChange, value } }) => (
<div className="flex flex-col space-y-2">
<Label>{t("common.role_organization")}</Label>
<Label>{t("environments.settings.teams.organization_role")}</Label>
<Select
defaultValue={isAccessControlAllowed ? "member" : "owner"}
disabled={!isAccessControlAllowed}

View File

@@ -4,12 +4,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId, getTeamsWhereUserIsAdmin } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
teamUser: { findUnique: vi.fn(), findMany: vi.fn() },
},
}));
@@ -19,6 +19,7 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
const mockOrganizationId = "org-1";
describe("roles lib", () => {
beforeEach(() => {
@@ -90,7 +91,7 @@ describe("roles lib", () => {
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" } as unknown as any);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
@@ -110,4 +111,47 @@ describe("roles lib", () => {
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
describe("getTeamsWhereUserIsAdmin", () => {
test("returns empty array if user is not admin of any team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([]);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual([]);
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockOrganizationId, expect.anything()]
);
});
test("returns array of team IDs where user is admin", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([
{ teamId: "team-1" },
{ teamId: "team-2" },
{ teamId: "team-3" },
] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1", "team-2", "team-3"]);
});
test("returns single team ID when user is admin of one team", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValueOnce([{ teamId: "team-1" }] as unknown as any);
const result = await getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId);
expect(result).toEqual(["team-1"]);
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findMany).mockRejectedValueOnce(error);
await expect(getTeamsWhereUserIsAdmin(mockUserId, mockOrganizationId)).rejects.toThrow(error);
});
});
});

View File

@@ -83,3 +83,31 @@ export const getTeamRoleByTeamIdUserId = reactCache(
}
}
);
export const getTeamsWhereUserIsAdmin = reactCache(
async (userId: string, organizationId: string): Promise<string[]> => {
validateInputs([userId, ZId], [organizationId, ZId]);
try {
const adminTeams = await prisma.teamUser.findMany({
where: {
userId,
role: "admin",
team: {
organizationId,
},
},
select: {
teamId: true,
},
});
return adminTeams.map((at) => at.teamId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -43,7 +43,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
<IdBadge id={team.id} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>

View File

@@ -9,10 +9,9 @@ import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
interface AccessViewProps {
teams: TProjectTeam[];
environmentId: string;
isOwnerOrManager: boolean;
}
export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessViewProps) => {
export const AccessView = ({ teams, environmentId }: AccessViewProps) => {
const { t } = useTranslation();
return (
<>
@@ -20,7 +19,7 @@ export const AccessView = ({ teams, environmentId, isOwnerOrManager }: AccessVie
title={t("common.team_access")}
description={t("environments.project.teams.team_settings_description")}>
<div className="mb-4 flex justify-end">
<ManageTeam environmentId={environmentId} isOwnerOrManager={isOwnerOrManager} />
<ManageTeam environmentId={environmentId} />
</div>
<AccessTable teams={teams} />
</SettingsCard>

View File

@@ -3,14 +3,12 @@
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface ManageTeamProps {
environmentId: string;
isOwnerOrManager: boolean;
}
export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps) => {
export const ManageTeam = ({ environmentId }: ManageTeamProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -19,20 +17,9 @@ export const ManageTeam = ({ environmentId, isOwnerOrManager }: ManageTeamProps)
router.push(`/environments/${environmentId}/settings/teams`);
};
if (isOwnerOrManager) {
return (
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
}
return (
<TooltipRenderer
tooltipContent={t("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")}>
<Button variant="secondary" size="sm" disabled>
{t("environments.project.teams.manage_teams")}
</Button>
</TooltipRenderer>
<Button variant="secondary" size="sm" onClick={handleManageTeams}>
{t("environments.project.teams.manage_teams")}
</Button>
);
};

View File

@@ -10,7 +10,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
const t = await getTranslate();
const params = await props.params;
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
const { project } = await getEnvironmentAuth(params.environmentId);
const teams = await getTeamsByProjectId(project.id);
@@ -18,14 +18,12 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
throw new Error(t("common.teams_not_found"));
}
const isOwnerOrManager = isOwner || isManager;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="teams" />
</PageHeader>
<AccessView environmentId={params.environmentId} teams={teams} isOwnerOrManager={isOwnerOrManager} />
<AccessView environmentId={params.environmentId} teams={teams} />
</PageContentWrapper>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -80,6 +80,16 @@ export const TeamSettingsModal = ({
const router = useRouter();
// Track initial member IDs to distinguish existing members from newly added ones
const initialMemberIds = useMemo(() => {
return new Set(team.members.map((member) => member.userId));
}, [team.members]);
// Track initial project IDs to distinguish existing projects from newly added ones
const initialProjectIds = useMemo(() => {
return new Set(team.projects.map((project) => project.projectId));
}, [team.projects]);
const initialMembers = useMemo(() => {
const members = team.members.map((member) => ({
userId: member.userId,
@@ -259,34 +269,44 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={!isOwnerOrManager && !isTeamAdminMember}
value={member.userId}>
<SelectTrigger>
<SelectValue placeholder="Select member" />
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
render={({ field, fieldState: { error } }) => {
// Disable user select for existing members (can only remove or change role)
const isExistingMember =
member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled =
isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
@@ -328,18 +348,20 @@ export const TeamSettingsModal = ({
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
@@ -360,7 +382,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_members_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddMember}
@@ -396,31 +418,40 @@ export const TeamSettingsModal = ({
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
)}
render={({ field, fieldState: { error } }) => {
// Disable project select for existing projects (can only remove or change permission)
const isExistingProject =
project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_project")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
@@ -481,7 +512,7 @@ export const TeamSettingsModal = ({
: t("environments.settings.teams.all_projects_added")
}>
<Button
size="default"
size="sm"
type="button"
variant="secondary"
onClick={handleAddProject}

View File

@@ -1,6 +1,10 @@
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
import {
Column,
Container,
ElementHeader,
Button as EmailButton,
Img,
Link,
@@ -8,11 +12,8 @@ import {
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
render,
} from "@formbricks/email";
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
@@ -24,7 +25,6 @@ import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { ElementHeader } from "./email-element-header";
interface PreviewEmailTemplateProps {
survey: TSurvey;
@@ -183,7 +183,7 @@ export async function PreviewEmailTemplate({
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
<EmailButton
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base leading-4 font-medium no-underline shadow-none"
href={ctaElement.buttonUrl}>
<Text className="inline">
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
@@ -306,13 +306,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -360,11 +360,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 break-words px-4 py-2" />
<Column className="w-40 px-4 py-2 break-words" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
key={column.id}>
{getLocalizedValue(column.label, "default")}
</Column>
@@ -376,7 +376,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
<Column className="w-40 break-words px-4 py-2">
<Column className="w-40 px-4 py-2 break-words">
{getLocalizedValue(row.label, "default")}
</Column>
{firstQuestion.columns.map((column) => {

View File

@@ -1,30 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface ForgotPasswordEmailProps {
verifyLink: string;
}
export async function ForgotPasswordEmail({
verifyLink,
}: ForgotPasswordEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default ForgotPasswordEmail;

View File

@@ -1,34 +0,0 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface VerificationEmailProps {
readonly verifyLink: string;
}
export async function NewEmailVerification({
verifyLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default NewEmailVerification;

View File

@@ -1,20 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
export async function PasswordResetNotifyEmail(): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default PasswordResetNotifyEmail;

View File

@@ -1,26 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmailCustomizationPreviewEmailProps {
userName: string;
logoUrl?: string;
}
export async function EmailCustomizationPreviewEmail({
userName,
logoUrl,
}: EmailCustomizationPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);
}
export default EmailCustomizationPreviewEmail;

View File

@@ -1,33 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteAcceptedEmailProps {
inviterName: string;
inviteeName: string;
}
export async function InviteAcceptedEmail({
inviterName,
inviteeName,
}: InviteAcceptedEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_accepted_email_heading", { inviterName })} {inviterName}
</Heading>
<Text className="text-sm">
{t("emails.invite_accepted_email_text_par1", { inviteeName })} {inviteeName}{" "}
{t("emails.invite_accepted_email_text_par2")}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteAcceptedEmail;

View File

@@ -1,37 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteEmailProps {
inviteeName: string;
inviterName: string;
verifyLink: string;
}
export async function InviteEmail({
inviteeName,
inviterName,
verifyLink,
}: InviteEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_email_heading", { inviteeName })} {inviteeName}
</Heading>
<Text className="text-sm">
{t("emails.invite_email_text_par1", { inviterName })} {inviterName}{" "}
{t("emails.invite_email_text_par2")}
</Text>
<EmailButton href={verifyLink} label={t("emails.invite_email_button_label")} />
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
logoUrl?: string;
}
export async function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
}: EmbedSurveyPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>
</Container>
</EmailTemplate>
);
}
export default EmbedSurveyPreviewEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
logoUrl: string;
}
export async function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
}: LinkSurveyEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default LinkSurveyEmail;

View File

@@ -1,6 +1,18 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import {
renderEmailCustomizationPreviewEmail,
renderEmbedSurveyPreviewEmail,
renderForgotPasswordEmail,
renderInviteAcceptedEmail,
renderInviteEmail,
renderLinkSurveyEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderResponseFinishedEmail,
renderVerificationEmail,
} from "@formbricks/email";
import { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
@@ -9,8 +21,11 @@ import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
IMPRINT_ADDRESS,
IMPRINT_URL,
MAIL_FROM,
MAIL_FROM_NAME,
PRIVACY_URL,
SMTP_AUTHENTICATED,
SMTP_HOST,
SMTP_PASSWORD,
@@ -18,25 +33,24 @@ import {
SMTP_REJECT_UNAUTHORIZED_TLS,
SMTP_SECURE_ENABLED,
SMTP_USER,
TERMS_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
const legalProps: TEmailTemplateLegalProps = {
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
};
interface SendEmailDataProps {
to: string;
replyTo?: string;
@@ -89,7 +103,7 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi
const token = createEmailChangeToken(id, email);
const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`;
const html = await render(await NewEmailVerification({ verifyLink }));
const html = await renderNewEmailVerification({ verifyLink, t, ...legalProps });
return await sendEmail({
to: email,
@@ -117,7 +131,12 @@ export const sendVerificationEmail = async ({
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?token=${encodeURIComponent(token)}`;
const html = await render(await VerificationEmail({ verificationRequestLink, verifyLink }));
const html = await renderVerificationEmail({
verificationRequestLink,
verifyLink,
t,
...legalProps,
});
return await sendEmail({
to: email,
@@ -140,7 +159,7 @@ export const sendForgotPasswordEmail = async (user: {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const html = await render(await ForgotPasswordEmail({ verifyLink }));
const html = await renderForgotPasswordEmail({ verifyLink, t, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),
@@ -150,7 +169,7 @@ export const sendForgotPasswordEmail = async (user: {
export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await PasswordResetNotifyEmail());
const html = await renderPasswordResetNotifyEmail({ t, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.password_reset_notify_email_subject"),
@@ -171,7 +190,7 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
const html = await renderInviteEmail({ inviteeName, inviterName, verifyLink, t, ...legalProps });
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
@@ -185,7 +204,7 @@ export const sendInviteAcceptedEmail = async (
email: string
): Promise<void> => {
const t = await getTranslate();
const html = await render(await InviteAcceptedEmail({ inviteeName, inviterName }));
const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t, ...legalProps });
await sendEmail({
to: email,
subject: t("emails.invite_accepted_email_subject"),
@@ -208,16 +227,20 @@ export const sendResponseFinishedEmail = async (
throw new Error("Organization not found");
}
const html = await render(
await ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
})
);
// Pre-process the element response mapping before passing to email
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t,
...legalProps,
});
await sendEmail({
to: email,
@@ -241,7 +264,13 @@ export const sendEmbedSurveyPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, logoUrl }));
const html = await renderEmbedSurveyPreviewEmail({
html: innerHtml,
environmentId,
logoUrl,
t,
...legalProps,
});
return await sendEmail({
to,
subject: t("emails.embed_survey_preview_email_subject"),
@@ -255,7 +284,12 @@ export const sendEmailCustomizationPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const emailHtmlBody = await render(await EmailCustomizationPreviewEmail({ userName, logoUrl }));
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({
userName,
logoUrl,
t,
...legalProps,
});
return await sendEmail({
to,
@@ -280,7 +314,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
};
const surveyLink = getSurveyLink();
const html = await render(await LinkSurveyEmail({ surveyName, surveyLink, logoUrl }));
const html = await renderLinkSurveyEmail({ surveyName, surveyLink, logoUrl, t, ...legalProps });
return await sendEmail({
to: data.email,
subject: t("emails.verified_link_survey_email_subject"),

View File

@@ -4,7 +4,7 @@ import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { createInviteToken } from "@/lib/jwt";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -16,6 +16,7 @@ import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { sendInviteMemberEmail } from "@/modules/email";
import {
deleteMembership,
@@ -23,6 +24,7 @@ import {
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
import { enrollInSecurityUpdates } from "./lib/security-updates";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -195,19 +197,55 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
)
);
const validateTeamAdminInvitePermissions = (
inviterRole: TOrganizationRole,
inviterAdminTeams: string[],
inviteRole: TOrganizationRole,
inviteTeamIds: string[]
): void => {
const isOrgOwnerOrManager = inviterRole === "owner" || inviterRole === "manager";
const isTeamAdmin = inviterAdminTeams.length > 0;
if (!isOrgOwnerOrManager && !isTeamAdmin) {
throw new AuthenticationError("Only organization owners, managers, or team admins can invite members");
}
// Team admins have restrictions
if (isTeamAdmin && !isOrgOwnerOrManager) {
if (inviteRole !== "member") {
throw new OperationNotAllowedError("Team admins can only invite users as members");
}
const invalidTeams = inviteTeamIds.filter((id) => !inviterAdminTeams.includes(id));
if (invalidTeams.length > 0) {
throw new OperationNotAllowedError("Team admins can only add users to teams where they are admin");
}
if (inviteTeamIds.length === 0) {
throw new ValidationError("Team admins must add invited users to at least one team");
}
}
};
const ZInviteUserAction = z.object({
organizationId: ZId,
email: z.string(),
name: z.string(),
name: z.string().trim().min(1, "Name is required"),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
teamIds: z.array(ZId),
});
export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserAction).action(
withAuditLogging(
"created",
"invite",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZInviteUserAction>;
}) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
@@ -224,16 +262,41 @@ export const inviteUserAction = authenticatedActionClient.schema(ZInviteUserActi
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const isOrgOwnerOrManager =
currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
// Fetch user's admin teams (empty array if owner/manager to skip unnecessary query)
const userAdminTeams = isOrgOwnerOrManager
? []
: await getTeamsWhereUserIsAdmin(ctx.user.id, parsedInput.organizationId);
const isTeamAdmin = userAdminTeams.length > 0;
if (!isOrgOwnerOrManager && !isTeamAdmin) {
throw new AuthenticationError("Not authorized to invite members");
}
if (isOrgOwnerOrManager) {
// Standard org-level auth check
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
}
// Validate team admin restrictions
validateTeamAdminInvitePermissions(
currentUserMembership.role,
userAdminTeams,
parsedInput.role,
parsedInput.teamIds
);
if (currentUserMembership.role === "manager" && parsedInput.role !== "member") {
throw new OperationNotAllowedError("Managers can only invite users as members");
@@ -325,3 +388,39 @@ export const leaveOrganizationAction = authenticatedActionClient.schema(ZLeaveOr
}
)
);
const ZEnrollSecurityUpdatesAction = z.object({
organizationId: ZId,
});
export const enrollSecurityUpdatesAction = authenticatedActionClient
.schema(ZEnrollSecurityUpdatesAction)
.action(async ({ ctx, parsedInput }) => {
// Ensure this is only called for self-hosted instances
if (IS_FORMBRICKS_CLOUD) {
throw new OperationNotAllowedError(
"Security updates enrollment is only available for self-hosted instances"
);
}
// Only owners can enroll in security updates
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
// Enroll with the current user's email
const result = await enrollInSecurityUpdates(ctx.user.email);
if (!result.success) {
throw new Error("Failed to enroll in security updates");
}
return { success: true };
});

View File

@@ -37,6 +37,8 @@ interface OrganizationActionsProps {
isMultiOrgEnabled: boolean;
isUserManagementDisabledFromUi: boolean;
isStorageConfigured: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const OrganizationActions = ({
@@ -52,16 +54,20 @@ export const OrganizationActions = ({
isMultiOrgEnabled,
isUserManagementDisabledFromUi,
isStorageConfigured,
isTeamAdmin,
userAdminTeamIds,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isLeaveOrganizationModalOpen, setLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setInviteMemberModalOpen] = useState(false);
const [isLeaveOrganizationModalOpen, setIsLeaveOrganizationModalOpen] = useState(false);
const [isInviteMemberModalOpen, setIsInviteMemberModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const canInvite = isOwnerOrManager || (isAccessControlAllowed && isTeamAdmin);
const handleLeaveOrganization = async () => {
setLoading(true);
try {
@@ -134,18 +140,18 @@ export const OrganizationActions = ({
<>
<div className="mb-4 flex justify-end space-x-2 text-right">
{role !== "owner" && isMultiOrgEnabled && (
<Button variant="secondary" size="sm" onClick={() => setLeaveOrganizationModalOpen(true)}>
<Button variant="destructive" size="sm" onClick={() => setIsLeaveOrganizationModalOpen(true)}>
{t("environments.settings.general.leave_organization")}
<XIcon />
</Button>
)}
{!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
{!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && (
<Button
size="sm"
variant="secondary"
variant="default"
onClick={() => {
setInviteMemberModalOpen(true);
setIsInviteMemberModalOpen(true);
}}>
{t("environments.settings.teams.invite_member")}
</Button>
@@ -153,7 +159,7 @@ export const OrganizationActions = ({
</div>
<InviteMemberModal
open={isInviteMemberModalOpen}
setOpen={setInviteMemberModalOpen}
setOpen={setIsInviteMemberModalOpen}
onSubmit={handleAddMembers}
membershipRole={membershipRole}
isAccessControlAllowed={isAccessControlAllowed}
@@ -161,9 +167,12 @@ export const OrganizationActions = ({
environmentId={environmentId}
teams={teams}
isStorageConfigured={isStorageConfigured}
isOwnerOrManager={isOwnerOrManager}
isTeamAdmin={isTeamAdmin}
userAdminTeamIds={userAdminTeamIds}
/>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("environments.settings.general.leave_organization_title")}</DialogTitle>
@@ -177,7 +186,7 @@ export const OrganizationActions = ({
</p>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setLeaveOrganizationModalOpen(false)}>
<Button variant="secondary" onClick={() => setIsLeaveOrganizationModalOpen(false)}>
{t("common.cancel")}
</Button>
<Button

View File

@@ -7,13 +7,14 @@ import { useRouter } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserName } from "@formbricks/types/user";
import { AddMemberRole } from "@/modules/ee/role-management/components/add-member-role";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { MultiSelect } from "@/modules/ui/components/multi-select";
@@ -27,6 +28,7 @@ interface IndividualInviteTabProps {
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
showTeamAdminRestrictions: boolean;
}
export const IndividualInviteTab = ({
@@ -37,22 +39,32 @@ export const IndividualInviteTab = ({
isFormbricksCloud,
environmentId,
membershipRole,
showTeamAdminRestrictions,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
email: z.string().min(1, { message: "Email is required" }).email({ message: "Invalid email" }),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
teamIds: showTeamAdminRestrictions
? z.array(ZId).min(1, { message: "Team admins must select at least one team" })
: z.array(ZId),
});
const router = useRouter();
type TFormData = z.infer<typeof ZFormSchema>;
const { t } = useTranslation();
// Determine default role based on permissions
let defaultRole: TOrganizationRole = "owner";
if (showTeamAdminRestrictions || isAccessControlAllowed) {
defaultRole = "member";
}
const form = useForm<TFormData>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
role: isAccessControlAllowed ? "member" : "owner",
role: defaultRole,
teamIds: [],
},
});
@@ -104,43 +116,61 @@ export const IndividualInviteTab = ({
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>}
</div>
<div>
<AddMemberRole
control={control}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>{t("environments.settings.teams.member_role_info_message")}</AlertDescription>
</Alert>
{showTeamAdminRestrictions ? (
<div className="flex flex-col space-y-2">
<Label htmlFor="memberRoleSelect">{t("environments.settings.teams.organization_role")}</Label>
<Input value={t("environments.settings.teams.member")} disabled />
</div>
) : (
<>
<AddMemberRole
control={control}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
membershipRole={membershipRole}
/>
{watch("role") === "member" && (
<Alert className="mt-2" variant="info">
<AlertDescription>
{t("environments.settings.teams.member_role_info_message")}
</AlertDescription>
</Alert>
)}
</>
)}
</div>
{isAccessControlAllowed && (
<FormField
control={control}
name="teamIds"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>{t("common.add_to_team")} </FormLabel>
<div className="space-y-2">
<MultiSelect
value={field.value}
options={teamOptions}
placeholder={t("environments.settings.teams.team_select_placeholder")}
disabled={!teamOptions.length}
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="font-normal text-amber-600">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
</div>
</FormItem>
)}
/>
<>
<FormField
control={control}
name="teamIds"
render={({ field }) => (
<FormItem className="flex flex-col space-y-2">
<FormLabel>{t("common.add_to_team")} </FormLabel>
<div className="space-y-2">
<MultiSelect
value={field.value}
options={teamOptions}
placeholder={t("environments.settings.teams.team_select_placeholder")}
disabled={!teamOptions.length}
onChange={(val) => field.onChange(val)}
/>
{!teamOptions.length && (
<Small className="font-normal text-amber-600">
{t("environments.settings.teams.create_first_team_message")}
</Small>
)}
</div>
<FormError>{errors.teamIds?.message}</FormError>
</FormItem>
)}
/>
<div className="flex flex-col space-y-2">
<Label htmlFor="teamRoleInput">{t("common.team_role")}</Label>
<Input value={t("environments.settings.teams.contributor")} disabled />
</div>
</>
)}
{!isAccessControlAllowed && (

View File

@@ -26,6 +26,9 @@ interface InviteMemberModalProps {
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
isOwnerOrManager: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
}
export const InviteMemberModal = ({
@@ -38,11 +41,21 @@ export const InviteMemberModal = ({
environmentId,
membershipRole,
isStorageConfigured,
isOwnerOrManager,
isTeamAdmin,
userAdminTeamIds,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
const { t } = useTranslation();
const showTeamAdminRestrictions = !isOwnerOrManager && isTeamAdmin;
const filteredTeams =
showTeamAdminRestrictions && userAdminTeamIds
? teams.filter((t) => userAdminTeamIds.includes(t.id))
: teams;
const tabs = {
individual: (
<IndividualInviteTab
@@ -51,8 +64,9 @@ export const InviteMemberModal = ({
onSubmit={onSubmit}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
teams={teams}
teams={filteredTeams}
membershipRole={membershipRole}
showTeamAdminRestrictions={showTeamAdminRestrictions}
/>
),
bulk: (
@@ -75,16 +89,18 @@ export const InviteMemberModal = ({
</DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained>
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
{tabs[type]}
{!showTeamAdminRestrictions && (
<TabToggle
id="type"
options={[
{ value: "individual", label: t("environments.settings.teams.individual") },
{ value: "bulk", label: t("environments.settings.teams.bulk_invite") },
]}
onChange={(inviteType) => setType(inviteType)}
defaultSelected={type}
/>
)}
{showTeamAdminRestrictions ? tabs.individual : tabs[type]}
</DialogBody>
</DialogContent>
</Dialog>

View File

@@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships";
@@ -45,6 +46,10 @@ export const MembersView = async ({
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
// Fetch admin teams if they're a team admin
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
let teams: TOrganizationTeam[] = [];
if (isAccessControlAllowed) {
@@ -69,6 +74,8 @@ export const MembersView = async ({
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
isTeamAdmin={isTeamAdminUser}
userAdminTeamIds={userAdminTeamIds}
/>
)}

View File

@@ -0,0 +1,98 @@
"use client";
import { ShieldCheckIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { enrollSecurityUpdatesAction } from "@/modules/organization/settings/teams/actions";
import { TSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
import { Button } from "@/modules/ui/components/button";
import { H4, P } from "@/modules/ui/components/typography";
interface SecurityUpdatesCardProps {
organizationId: string;
userEmail: string;
securityUpdatesStatus: TSecurityUpdatesStatus;
}
export const SecurityUpdatesCard = ({
organizationId,
userEmail,
securityUpdatesStatus,
}: SecurityUpdatesCardProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isEnrolling, setIsEnrolling] = useState(false);
const handleEnroll = async () => {
setIsEnrolling(true);
try {
const result = await enrollSecurityUpdatesAction({ organizationId });
if (result?.data?.success) {
toast.success(t("environments.settings.teams.security_updates_enrolled_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
console.error(error);
} finally {
setIsEnrolling(false);
}
};
const isEnrolled = securityUpdatesStatus.enrolled;
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border bg-white shadow-sm",
isEnrolled ? "border-green-200 bg-green-50" : "border-slate-200"
)}>
<div className="flex items-start justify-between p-6">
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full",
isEnrolled ? "bg-green-100" : "bg-slate-100"
)}>
<ShieldCheckIcon className={cn("h-5 w-5", isEnrolled ? "text-green-600" : "text-slate-600")} />
</div>
<div className="flex flex-col gap-1">
<H4 className="font-medium tracking-normal">
{t("environments.settings.teams.security_updates_title")}
</H4>
<P className="!mt-0 text-sm text-slate-500">
{isEnrolled
? t("environments.settings.teams.security_updates_enrolled_description", {
email: securityUpdatesStatus.email || userEmail,
})
: t("environments.settings.teams.security_updates_description")}
</P>
</div>
</div>
{!isEnrolled && (
<Button onClick={handleEnroll} disabled={isEnrolling} className="shrink-0">
{isEnrolling
? t("environments.settings.teams.security_updates_enrolling")
: t("environments.settings.teams.security_updates_enroll")}
</Button>
)}
{isEnrolled && (
<div className="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">
{t("environments.settings.teams.security_updates_enrolled")}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
"use server";
import { getInstanceId } from "@/lib/instance";
export type TSecurityUpdatesStatus = {
enrolled: boolean;
email?: string;
};
/**
* Checks if the current instance is enrolled in security updates.
*
* TODO: Replace with actual EE server call
* GET /security-updates/status?instanceId=xxx
*
* @returns The enrollment status and email if enrolled
*/
export const getSecurityUpdatesStatus = async (): Promise<TSecurityUpdatesStatus> => {
const instanceId = await getInstanceId();
if (!instanceId) {
return { enrolled: false };
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`);
// if (!response.ok) {
// return { enrolled: false };
// }
// return await response.json();
// Mock: Always return not enrolled for now
return { enrolled: false };
};
/**
* Enrolls the current instance in security updates.
*
* TODO: Replace with actual EE server call
* POST /security-updates/enroll { instanceId, email }
*
* @param email - The email address to receive security updates
* @returns Success status
*/
export const enrollInSecurityUpdates = async (email: string): Promise<{ success: boolean }> => {
const instanceId = await getInstanceId();
if (!instanceId) {
throw new Error("Instance ID not found");
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`, {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ instanceId, email }),
// });
//
// if (!response.ok) {
// throw new Error("Failed to enroll in security updates");
// }
//
// return await response.json();
// Mock: Always succeed for now
console.log(`[Mock] Enrolling instance ${instanceId} with email ${email}`);
return { success: true };
};

View File

@@ -1,26 +1,48 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
import { SecurityUpdatesCard } from "@/modules/organization/settings/teams/components/security-updates-card";
import { getSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export const TeamsPage = async (props) => {
export const TeamsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const { session, currentUserMembership, organization, isOwner } = await getEnvironmentAuth(
params.environmentId
);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
// Check if user has standard user management access (owner/manager)
const hasStandardUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
// Also check if user is a team admin (they get limited user management for invites)
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
const isTeamAdminUser = userAdminTeamIds.length > 0;
// Allow user management UI if they're owner/manager OR team admin (when access control is enabled)
const hasUserManagementAccess =
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
// Fetch security updates status for self-hosted instances only (owners only)
const shouldShowSecurityUpdates = !IS_FORMBRICKS_CLOUD && isOwner;
const [securityUpdatesStatus, user] = shouldShowSecurityUpdates
? await Promise.all([getSecurityUpdatesStatus(), getUser(session.user.id)])
: [null, null];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -31,6 +53,15 @@ export const TeamsPage = async (props) => {
activeId="teams"
/>
</PageHeader>
{securityUpdatesStatus && user && (
<SecurityUpdatesCard
organizationId={organization.id}
userEmail={user.email}
securityUpdatesStatus={securityUpdatesStatus}
/>
)}
<MembersView
membershipRole={currentUserMembership?.role}
organization={organization}

View File

@@ -38,6 +38,7 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
publicDomain: string;
}
export const ThemeStyling = ({
@@ -47,6 +48,7 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -199,6 +201,7 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
publicDomain={publicDomain}
/>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -27,6 +28,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -49,6 +51,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
publicDomain={publicDomain}
/>
</SettingsCard>
<SettingsCard

View File

@@ -19,13 +19,8 @@ export const createSurvey = async (
try {
const { createdBy, ...restSurveyBody } = surveyBody;
const hasLanguages = Array.isArray(restSurveyBody.languages)
? restSurveyBody.languages.length > 0
: restSurveyBody.languages &&
typeof restSurveyBody.languages === "object" &&
"create" in restSurveyBody.languages;
if (!hasLanguages) {
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}

View File

@@ -20,7 +20,8 @@ import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
import { updateSurvey, updateSurveyDraft } from "@/modules/survey/editor/lib/survey";
import { TSurveyDraft, ZSurveyDraft } from "@/modules/survey/editor/types/survey";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
@@ -46,6 +47,62 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyDraftAction = authenticatedActionClient.schema(ZSurveyDraft).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurveyDraft }) => {
// Cast to TSurvey - ZSurveyDraft validates structure, full validation happens on publish
const survey = parsedInput as TSurvey;
const organizationId = await getOrganizationIdFromSurveyId(survey.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(survey.id),
minPermission: "readWrite",
},
],
});
if (survey.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (survey.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (survey.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = survey.id;
const oldObject = await getSurvey(survey.id);
await checkExternalUrlsPermission(organizationId, survey, oldObject);
// Use the draft version that skips validation
const result = await updateSurveyDraft(survey);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
return result;
}
)
);
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",

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