mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 23:45:23 -06:00
Compare commits
5 Commits
v3.11.0
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fba692626 | ||
|
|
3ace91cdd5 | ||
|
|
4ba7bf5b3c | ||
|
|
bd1402a58b | ||
|
|
c2af0c3fb6 |
@@ -211,5 +211,5 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
84
.github/dependabot.yml
vendored
Normal file
84
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,11 +10,6 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@@ -25,6 +25,7 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -20,15 +20,18 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: "The version of the Helm chart to release"
|
||||
description: 'The version of the Helm chart to release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: "Terraform"
|
||||
name: 'Terraform'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,13 +14,14 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -70,7 +71,7 @@ jobs:
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -82,3 +83,4 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<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={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<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={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{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={"/"}>
|
||||
|
||||
@@ -225,7 +225,7 @@ 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>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
|
||||
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl, t]);
|
||||
}, [surveyUrl]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
|
||||
@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
|
||||
});
|
||||
|
||||
@@ -99,8 +99,12 @@ export const getEnvironmentState = async (
|
||||
getActionClassesForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
|
||||
|
||||
@@ -100,11 +100,7 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
where: { environmentId },
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
@@ -120,11 +116,7 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
where: { environmentId },
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
|
||||
@@ -20,8 +20,6 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
|
||||
@@ -57,10 +57,6 @@ export const PUT = async (
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return responses.badRequestResponse("Response is already finished", undefined, true);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -41,13 +40,6 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
@@ -214,119 +206,4 @@ describe("checkSurveyValidity", () => {
|
||||
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: {},
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if meta.url is invalid", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url: "not-a-url" },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid URL in response metadata",
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if suId is missing from url", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?foo=bar";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
|
||||
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
|
||||
expect(resultEncryptedMismatch?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-2";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(_resultEncryptedMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
|
||||
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -26,55 +24,6 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
if (decryptedSuId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
} else if (responseInput.singleUseId !== suId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
if (!responseInput.recaptchaToken) {
|
||||
logger.error("Missing recaptcha token");
|
||||
|
||||
@@ -150,12 +150,7 @@ export const createActionClass = async (
|
||||
...actionClassInput,
|
||||
environment: { connect: { id: environmentId } },
|
||||
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
|
||||
noCodeConfig:
|
||||
actionClassInput.type === "noCode"
|
||||
? actionClassInput.noCodeConfig === null
|
||||
? undefined
|
||||
: actionClassInput.noCodeConfig
|
||||
: undefined,
|
||||
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
@@ -198,12 +193,7 @@ export const updateActionClass = async (
|
||||
...actionClassInput,
|
||||
environment: { connect: { id: environmentId } },
|
||||
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
|
||||
noCodeConfig:
|
||||
actionClassInput.type === "noCode"
|
||||
? actionClassInput.noCodeConfig === null
|
||||
? undefined
|
||||
: actionClassInput.noCodeConfig
|
||||
: undefined,
|
||||
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
|
||||
},
|
||||
select: {
|
||||
...selectActionClass,
|
||||
@@ -222,6 +212,7 @@ export const updateActionClass = async (
|
||||
id: result.id,
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
|
||||
for (const surveyId of surveyIds) {
|
||||
surveyCache.revalidate({
|
||||
|
||||
@@ -282,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
|
||||
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
|
||||
|
||||
@@ -104,7 +104,7 @@ export const env = createEnv({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
|
||||
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -199,6 +199,6 @@ export const env = createEnv({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
|
||||
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,21 +13,3 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
|
||||
isMember,
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserManagementAccess = (
|
||||
role: TOrganizationRole,
|
||||
minimumRole: "owner" | "manager" | "disabled"
|
||||
): boolean => {
|
||||
// If minimum role is "disabled", no one has access
|
||||
if (minimumRole === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (minimumRole === "owner") {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
if (minimumRole === "manager") {
|
||||
return role === "owner" || role === "manager";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import structuredClonePolyfill from "@ungap/structured-clone";
|
||||
|
||||
const structuredCloneExport =
|
||||
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
|
||||
let structuredCloneExport: typeof structuredClonePolyfill;
|
||||
|
||||
if (typeof structuredClone === "undefined") {
|
||||
structuredCloneExport = structuredClonePolyfill;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
structuredCloneExport = structuredClone;
|
||||
}
|
||||
|
||||
export { structuredCloneExport as structuredClone };
|
||||
|
||||
@@ -533,7 +533,6 @@ export const updateResponse = async (
|
||||
id: response.id,
|
||||
contactId: response.contact?.id,
|
||||
surveyId: response.surveyId,
|
||||
...(response.singleUseId ? { singleUseId: response.singleUseId } : {}),
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export const useIntervalWhenFocused = (
|
||||
callback: () => void,
|
||||
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
|
||||
) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
const handleFocus = () => {
|
||||
if (isActive) {
|
||||
if (shouldExecuteImmediately) {
|
||||
// Execute the callback immediately when the tab comes into focus
|
||||
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
|
||||
callback();
|
||||
}, intervalDuration);
|
||||
}
|
||||
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Clear the interval when the tab loses focus
|
||||
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isActive, intervalDuration, handleFocus]);
|
||||
}, [isActive, intervalDuration]);
|
||||
};
|
||||
|
||||
export default useIntervalWhenFocused;
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { isValidCallbackUrl } from "@/lib/utils/url";
|
||||
import { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -27,6 +28,24 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const enforceHttps = (request: NextRequest): Response | null => {
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{
|
||||
field: "",
|
||||
issue: "Only HTTPS connections are allowed on the management endpoints.",
|
||||
},
|
||||
],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
const token = await getToken({ req: request as any });
|
||||
|
||||
@@ -113,6 +132,12 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce HTTPS for management endpoints
|
||||
if (isManagementApiRoute(request.nextUrl.pathname)) {
|
||||
const httpsResponse = enforceHttps(request);
|
||||
if (httpsResponse) return httpsResponse;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const authResponse = await handleAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
autoFocus={true}
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
|
||||
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
|
||||
value={surveyUrl}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -39,7 +39,7 @@ export const QuestionSkip = ({
|
||||
background:
|
||||
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
|
||||
}}>
|
||||
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
|
||||
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
|
||||
</div>
|
||||
}
|
||||
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
return (
|
||||
<p
|
||||
key={rowValueInSelectedLanguage}
|
||||
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
|
||||
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
|
||||
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -104,10 +104,10 @@ export const ResponseNotes = ({
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
|
||||
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
@@ -116,7 +116,7 @@ export const ResponseNotes = ({
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
"space-y-2 rounded-t-lg px-2 pt-2 pb-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
@@ -127,7 +127,7 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 group-hover/hint:scale-110 hover:text-amber-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SingleResponseCardBody = ({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
@{part}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ export const SingleResponseCardHeader = ({
|
||||
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
|
||||
<div className="space-y-2 border-b border-slate-200 px-6 pt-4 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
{pageType === "response" && (
|
||||
|
||||
@@ -111,7 +111,6 @@ export const updateResponse = async (
|
||||
responseCache.revalidate({
|
||||
id: updatedResponse.id,
|
||||
surveyId: updatedResponse.surveyId,
|
||||
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -22,16 +21,6 @@ vi.mock("../utils", () => ({
|
||||
findAndDeleteUploadedFilesInResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byId: vi.fn(),
|
||||
byResponseId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
@@ -186,7 +175,7 @@ describe("Response Lib", () => {
|
||||
});
|
||||
|
||||
describe("updateResponse", () => {
|
||||
test("update the response and revalidate caches including singleUseId", async () => {
|
||||
test("update the response and revalidate caches", async () => {
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(response);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
@@ -195,39 +184,12 @@ describe("Response Lib", () => {
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: response.id,
|
||||
surveyId: response.surveyId,
|
||||
singleUseId: response.singleUseId,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("update the response and revalidate caches", async () => {
|
||||
const responseWithoutSingleUseId = { ...response, singleUseId: null };
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(responseWithoutSingleUseId);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
expect(prisma.response.update).toHaveBeenCalledWith({
|
||||
where: { id: responseId },
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: response.id,
|
||||
surveyId: response.surveyId,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(responseWithoutSingleUseId);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
return (
|
||||
<FormWrapper>
|
||||
<>
|
||||
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
|
||||
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
|
||||
{t("auth.verification-requested.please_confirm_your_email_address")}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-slate-700">
|
||||
|
||||
@@ -19,7 +19,7 @@ export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrim
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
className={cn("relative flex w-full touch-none items-center select-none", className)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
|
||||
<div
|
||||
|
||||
@@ -126,7 +126,7 @@ export const PricingCard = ({
|
||||
id={plan.id}
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm font-semibold leading-6"
|
||||
"text-sm leading-6 font-semibold"
|
||||
)}>
|
||||
{t(plan.name)}
|
||||
</h2>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SingleContactPage = async (props: {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
|
||||
<section className="pb-24 pt-6">
|
||||
<section className="pt-6 pb-24">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
<ResponseSection
|
||||
|
||||
@@ -229,52 +229,51 @@ export const upsertBulkContacts = async (
|
||||
|
||||
try {
|
||||
// Execute everything in ONE transaction
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
|
||||
acc[keyObj.key] = keyObj.id;
|
||||
return acc;
|
||||
}, {});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
|
||||
acc[keyObj.key] = keyObj.id;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Check for missing attribute keys and create them if needed.
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
// Check for missing attribute keys and create them if needed.
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
|
||||
if (keysToUpsert.size > 0) {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
const BATCH_SIZE = 10000;
|
||||
if (keysToUpsert.size > 0) {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
const BATCH_SIZE = 10000;
|
||||
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
@@ -290,59 +289,59 @@ export const upsertBulkContacts = async (
|
||||
RETURNING "id", "key"
|
||||
`;
|
||||
|
||||
// Update attribute key map with upserted keys
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
}
|
||||
// Update attribute key map with upserted keys
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new contacts -- should be at most 1000, no need to batch
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
// Create new contacts -- should be at most 1000, no need to batch
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
environmentId,
|
||||
}));
|
||||
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
environmentId,
|
||||
}));
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
)
|
||||
@@ -357,37 +356,33 @@ export const upsertBulkContacts = async (
|
||||
"value" = EXCLUDED."value",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContactsByEmail) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
},
|
||||
{
|
||||
timeout: 10 * 1000, // 10 seconds
|
||||
}
|
||||
);
|
||||
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContactsByEmail) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
});
|
||||
|
||||
return ok({
|
||||
contactIdxWithConflictingUserIds,
|
||||
|
||||
@@ -312,7 +312,7 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
|
||||
@@ -494,7 +494,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -643,7 +643,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
|
||||
@@ -171,7 +171,7 @@ export function TargetingCard({
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-6">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
strokeWidth={3}
|
||||
|
||||
@@ -126,7 +126,7 @@ export const ZContactBulkUploadRequest = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
contacts: z
|
||||
.array(ZContactBulkUploadContact)
|
||||
.max(250, { message: "Maximum 250 contacts allowed at a time." })
|
||||
.max(1000, { message: "Maximum 1000 contacts allowed at a time." })
|
||||
.superRefine((contacts, ctx) => {
|
||||
// Track all data in a single pass
|
||||
const seenEmails = new Set<string>();
|
||||
|
||||
@@ -228,7 +228,7 @@ export function EditLanguage({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm italic text-slate-500">
|
||||
<p className="text-sm text-slate-500 italic">
|
||||
{t("environments.project.languages.no_language_found")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function LanguageIndicator({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="absolute right-2 top-2">
|
||||
<div className="absolute top-2 right-2">
|
||||
<button
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-haspopup="true"
|
||||
|
||||
@@ -65,7 +65,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className={`absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 ${isOpen ? "" : "hidden"}`}>
|
||||
className={`ring-opacity-5 absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ${isOpen ? "" : "hidden"}`}>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -186,7 +186,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>
|
||||
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
|
||||
@@ -248,7 +248,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{projectLanguages.length <= 1 && (
|
||||
<div className="mb-4 text-sm italic text-slate-500">
|
||||
<div className="mb-4 text-sm text-slate-500 italic">
|
||||
{projectLanguages.length === 0
|
||||
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
|
||||
: t(
|
||||
@@ -260,7 +260,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<div className="my-4 space-y-4">
|
||||
<div>
|
||||
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
|
||||
<div className="text-sm italic text-slate-500">
|
||||
<div className="text-sm text-slate-500 italic">
|
||||
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -15,14 +15,14 @@ import { AuthenticationError, OperationNotAllowedError, ValidationError } from "
|
||||
|
||||
// Mock constants with getter functions to allow overriding in tests
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockUserManagementMinimumRole = "owner";
|
||||
let mockDisableUserManagement = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get USER_MANAGEMENT_MINIMUM_ROLE() {
|
||||
return mockUserManagementMinimumRole;
|
||||
get DISABLE_USER_MANAGEMENT() {
|
||||
return mockDisableUserManagement;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("Role Management Actions", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockIsFormbricksCloud = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockDisableUserManagement = false;
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
@@ -220,7 +220,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if user management is disabled", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockUserManagementMinimumRole = "disabled";
|
||||
mockDisableUserManagement = true;
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
@@ -231,12 +231,12 @@ describe("Role Management Actions", () => {
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is not allowed for your role"));
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
|
||||
});
|
||||
|
||||
test("throws error if billing role is not allowed in self-hosted", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -253,7 +253,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows billing role in cloud environment", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockDisableUserManagement = false;
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
@@ -274,7 +274,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if manager tries to assign a role other than member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -291,7 +291,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows manager to assign member role", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
@@ -312,7 +312,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("successful membership update as owner", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -88,13 +87,8 @@ export const updateMembershipAction = authenticatedActionClient
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
const hasUserManagementAccess = getUserManagementAccess(
|
||||
currentUserMembership.role,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE
|
||||
);
|
||||
|
||||
if (!hasUserManagementAccess) {
|
||||
throw new OperationNotAllowedError("User management is not allowed for your role");
|
||||
if (DISABLE_USER_MANAGEMENT) {
|
||||
throw new OperationNotAllowedError("User management is disabled");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
|
||||
import { Session } from "next-auth";
|
||||
import { TMembership, TMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Common mock IDs
|
||||
export const mockOrganizationId = "cblt7dwr7d0hvdifl4iw6d5x";
|
||||
export const mockUserId = "wl43gybf3pxmqqx3fcmsk8eb";
|
||||
export const mockInviteId = "dc0b6ea6-bb65-4a22-88e1-847df2e85af4";
|
||||
export const mockTargetUserId = "vevt9qm7sqmh44e3za6a2vzd";
|
||||
|
||||
// Mock user
|
||||
export const mockUser: TUser = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
identityProvider: "email",
|
||||
twoFactorEnabled: false,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
},
|
||||
locale: "en-US",
|
||||
imageUrl: null,
|
||||
role: null,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Mock session
|
||||
export const mockSession: Session = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
},
|
||||
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
// Mock organizations
|
||||
export const createMockOrganization = (plan: TOrganizationBillingPlan): TOrganization => ({
|
||||
id: mockOrganizationId,
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan,
|
||||
period: "monthly",
|
||||
periodStart: new Date(),
|
||||
limits: {
|
||||
projects: plan === "free" ? 3 : null,
|
||||
monthly: {
|
||||
responses: plan === "free" ? 1500 : null,
|
||||
miu: plan === "free" ? 2000 : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockOrganizationFree = createMockOrganization("free");
|
||||
export const mockOrganizationStartup = createMockOrganization("startup");
|
||||
export const mockOrganizationScale = createMockOrganization("scale");
|
||||
|
||||
// Mock membership data
|
||||
export const createMockMembership = (role: TMembership["role"]): TMembership => ({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role,
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
export const mockMembershipMember = createMockMembership("member");
|
||||
export const mockMembershipManager = createMockMembership("manager");
|
||||
export const mockMembershipOwner = createMockMembership("owner");
|
||||
|
||||
// Mock data payloads
|
||||
export const mockInviteDataMember: TInviteUpdateInput = { role: "member" };
|
||||
export const mockInviteDataOwner: TInviteUpdateInput = { role: "owner" };
|
||||
export const mockInviteDataBilling: TInviteUpdateInput = { role: "billing" };
|
||||
|
||||
export const mockMembershipUpdateMember: TMembershipUpdateInput = { role: "member" };
|
||||
export const mockMembershipUpdateOwner: TMembershipUpdateInput = { role: "owner" };
|
||||
export const mockMembershipUpdateBilling: TMembershipUpdateInput = { role: "billing" };
|
||||
|
||||
// Mock input objects for actions
|
||||
export const mockUpdateInviteInput = {
|
||||
inviteId: mockInviteId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockInviteDataMember,
|
||||
};
|
||||
|
||||
export const mockUpdateMembershipInput = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockMembershipUpdateMember,
|
||||
};
|
||||
|
||||
// Mock responses
|
||||
export const mockUpdatedMembership: TMembership = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role: "member",
|
||||
accepted: true,
|
||||
};
|
||||
257
apps/web/modules/ee/role-management/tests/actions.test.ts
Normal file
257
apps/web/modules/ee/role-management/tests/actions.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
mockInviteDataBilling,
|
||||
mockInviteDataOwner,
|
||||
mockMembershipManager,
|
||||
mockMembershipMember,
|
||||
mockMembershipUpdateBilling,
|
||||
mockMembershipUpdateOwner,
|
||||
mockOrganizationFree,
|
||||
mockOrganizationId,
|
||||
mockOrganizationScale,
|
||||
mockOrganizationStartup,
|
||||
mockSession,
|
||||
mockUpdateInviteInput,
|
||||
mockUpdateMembershipInput,
|
||||
mockUpdatedMembership,
|
||||
mockUser,
|
||||
} from "./__mocks__/actions.mock";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { checkRoleManagementPermission } from "../actions";
|
||||
import { updateInviteAction, updateMembershipAction } from "../actions";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getRoleManagementPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/invite", () => ({
|
||||
updateInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/membership", () => ({
|
||||
updateMembership: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants without importing the actual module
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_MULTI_ORG_ENABLED: true,
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-github-secret",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "test-azure-tenant-id",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-algorithm",
|
||||
SAML_DATABASE_URL: "test-saml-db-url",
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
DISABLE_USER_MANAGEMENT: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/errors", () => ({
|
||||
OperationNotAllowedError: vi.fn(),
|
||||
ValidationError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("role-management/actions.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
test("throws error when organization not found", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
|
||||
"Organization not found"
|
||||
);
|
||||
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
|
||||
test("throws error when role management is not allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationFree);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
|
||||
new OperationNotAllowedError("Role management is not allowed for this organization")
|
||||
);
|
||||
|
||||
expect(getRoleManagementPermission).toHaveBeenCalledWith("free");
|
||||
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
|
||||
test("succeeds when role management is allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationStartup);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).resolves.toBeUndefined();
|
||||
await expect(getRoleManagementPermission).toHaveBeenCalledWith("startup");
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInviteAction", () => {
|
||||
test("throws error when user is not a member of the organization", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
expect(await updateInviteAction(mockUpdateInviteInput)).toStrictEqual({
|
||||
serverError: "User not a member of this organization",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when billing role is not allowed in self-hosted", async () => {
|
||||
const inputWithBillingRole = {
|
||||
...mockUpdateInviteInput,
|
||||
data: mockInviteDataBilling,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateInviteAction(inputWithBillingRole)).toStrictEqual({
|
||||
serverError: "Something went wrong while executing the operation.",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when manager tries to assign non-member role", async () => {
|
||||
const inputWithOwnerRole = {
|
||||
...mockUpdateInviteInput,
|
||||
data: mockInviteDataOwner,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateInviteAction(inputWithOwnerRole)).toStrictEqual({
|
||||
serverError: "Managers can only invite members",
|
||||
});
|
||||
});
|
||||
|
||||
test("successfully updates invite", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateInvite).mockResolvedValue(true);
|
||||
|
||||
const result = await updateInviteAction(mockUpdateInviteInput);
|
||||
|
||||
expect(result).toEqual({ data: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMembershipAction", () => {
|
||||
test("throws error when user is not a member of the organization", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
expect(await updateMembershipAction(mockUpdateMembershipInput)).toStrictEqual({
|
||||
serverError: "User not a member of this organization",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when billing role is not allowed in self-hosted", async () => {
|
||||
const inputWithBillingRole = {
|
||||
...mockUpdateMembershipInput,
|
||||
data: mockMembershipUpdateBilling,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateMembershipAction(inputWithBillingRole)).toStrictEqual({
|
||||
serverError: "Something went wrong while executing the operation.",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when manager tries to assign non-member role", async () => {
|
||||
const inputWithOwnerRole = {
|
||||
...mockUpdateMembershipInput,
|
||||
data: mockMembershipUpdateOwner,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateMembershipAction(inputWithOwnerRole)).toStrictEqual({
|
||||
serverError: "Managers can only assign users to the member role",
|
||||
});
|
||||
});
|
||||
|
||||
test("successfully updates membership", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateMembership).mockResolvedValue(mockUpdatedMembership);
|
||||
|
||||
const result = await updateMembershipAction(mockUpdateMembershipInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: mockUpdatedMembership,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
|
||||
<div className="mb-10">
|
||||
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
|
||||
|
||||
<div className="mb-6 mt-2 flex items-center gap-4">
|
||||
<div className="mt-2 mb-6 flex items-center gap-4">
|
||||
{logoUrl && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
|
||||
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
|
||||
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pt-10 pb-4">
|
||||
<Image
|
||||
data-testid="email-customization-preview-image"
|
||||
src={logoUrl || fbLogoUrl}
|
||||
@@ -284,7 +284,7 @@ export const EmailCustomizationSettings = ({
|
||||
)}
|
||||
|
||||
{hasWhiteLabelPermission && isReadOnly && (
|
||||
<Alert variant="warning" className="mb-6 mt-4">
|
||||
<Alert variant="warning" className="mt-4 mb-6">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
|
||||
@@ -10,11 +10,11 @@ interface QuestionHeaderProps {
|
||||
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
<Text className={cn("text-question-color m-0 block text-base leading-6 font-semibold", className)}>
|
||||
{headline}
|
||||
</Text>
|
||||
{subheader && (
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
|
||||
<Text className="text-question-color m-0 block p-0 text-sm leading-6 font-normal">{subheader}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -72,8 +72,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm font-normal leading-6">
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm leading-6 font-normal">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -131,7 +131,7 @@ export async function PreviewEmailTemplate({
|
||||
)}>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
|
||||
className={`absolute top-0 left-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
|
||||
/>
|
||||
) : null}
|
||||
{i}
|
||||
@@ -162,8 +162,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -227,7 +227,7 @@ export async function PreviewEmailTemplate({
|
||||
<>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
|
||||
className={`absolute top-0 left-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
|
||||
@@ -315,13 +315,13 @@ export async function PreviewEmailTemplate({
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
@@ -369,11 +369,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={getLocalizedValue(column, "default")}>
|
||||
{getLocalizedValue(column, "default")}
|
||||
</Column>
|
||||
@@ -385,7 +385,7 @@ export async function PreviewEmailTemplate({
|
||||
<Row
|
||||
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
|
||||
key={getLocalizedValue(row, "default")}>
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
<Column className="w-40 px-4 py-2 break-words">
|
||||
{getLocalizedValue(row, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map((_) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold italic">
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -66,6 +66,6 @@ export const renderEmailResponseValue = async (
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
|
||||
return <Text className="mt-0 font-bold break-words whitespace-pre-wrap">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -84,7 +84,7 @@ export async function ResponseFinishedEmail({
|
||||
<Text className="mb-2 flex items-center gap-2 font-medium">
|
||||
{hiddenFieldId} <EyeOffIcon />
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const WebhookRowData = ({
|
||||
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedTriggersText(webhook, t)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
{timeSince(webhook.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -162,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
</div>
|
||||
<div className="grid-cols-9">
|
||||
{apiKeysLocal?.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
|
||||
<div className="flex h-12 items-center justify-center px-6 text-sm font-medium whitespace-nowrap text-slate-400">
|
||||
{t("environments.project.api_keys.no_api_keys_yet")}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,7 @@ const LoadingCard = () => {
|
||||
return (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg leading-6 font-medium">
|
||||
<span className="sr-only">{t("common.loading")}</span>
|
||||
</h3>
|
||||
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500">
|
||||
|
||||
@@ -71,7 +71,7 @@ export const InviteMemberModal = ({
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock(
|
||||
);
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: "owner",
|
||||
DISABLE_USER_MANAGEMENT: 0,
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-key",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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 { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -16,10 +15,6 @@ export const TeamsPage = async (props) => {
|
||||
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
const hasUserManagementAccess = getUserManagementAccess(
|
||||
currentUserMembership?.role,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -37,7 +32,7 @@ export const TeamsPage = async (props) => {
|
||||
currentUserId={session.user.id}
|
||||
environmentId={params.environmentId}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isUserManagementDisabledFromUi={!hasUserManagementAccess}
|
||||
isUserManagementDisabledFromUi={DISABLE_USER_MANAGEMENT}
|
||||
/>
|
||||
<TeamsView
|
||||
organizationId={organization.id}
|
||||
|
||||
@@ -149,7 +149,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{t("common.loading")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<div className="w-full items-center">
|
||||
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<div className="pointer-events-none flex cursor-not-allowed items-center space-x-2 select-none">
|
||||
<Switch id="signature" checked={false} />
|
||||
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
|
||||
</div>
|
||||
|
||||
@@ -99,12 +99,12 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-1 my-auto flex items-center justify-center gap-2 text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div>
|
||||
{isMergingTags ? (
|
||||
<div className="w-24">
|
||||
@@ -139,7 +139,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent"
|
||||
onClick={() => setOpenDeleteTagDialog(true)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export const RecallItemSelect = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
<div className="max-h-72 overflow-x-hidden overflow-y-auto">
|
||||
{filteredRecallItems.map((recallItem, index) => {
|
||||
const IconComponent = getRecallItemIcon(recallItem);
|
||||
return (
|
||||
@@ -201,7 +201,7 @@ export const RecallItemSelect = ({
|
||||
}
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
<p className="max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap">
|
||||
{getRecallLabel(recallItem.label)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -220,7 +220,7 @@ export const RecallWrapper = ({
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
className="z-30 flex h-fit cursor-pointer justify-center rounded-md bg-slate-100 text-sm whitespace-pre text-transparent"
|
||||
key={`recall-${parts.length}`}>
|
||||
{"@" + label}
|
||||
</span>
|
||||
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
className="absolute top-full right-2 z-[1] flex h-6 cursor-pointer items-center rounded-t-none rounded-b-lg bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
|
||||
@@ -271,7 +271,7 @@ export const QuestionFormInput = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mb-2 mt-3">
|
||||
<div className="mt-3 mb-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -342,7 +342,7 @@ export const QuestionFormInput = ({
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
ref={highlightContainerRef}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
dir="auto"
|
||||
|
||||
@@ -45,10 +45,10 @@ export const StartFromScratchTemplate = ({
|
||||
activeTemplate?.name === customSurvey.name
|
||||
? "ring-brand-dark border-transparent ring-2"
|
||||
: "hover:border-brand-dark border-dashed border-slate-300",
|
||||
"duration-120 group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-150"
|
||||
"group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-120 duration-150"
|
||||
)}>
|
||||
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{customSurvey.name}</h3>
|
||||
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{customSurvey.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
|
||||
{activeTemplate?.name === customSurvey.name && (
|
||||
<div className="text-left">
|
||||
|
||||
@@ -41,7 +41,7 @@ export const TemplateFilters = ({
|
||||
className={cn(
|
||||
selectedFilter[index] === null
|
||||
? "bg-slate-800 font-semibold text-white"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{index === 0
|
||||
@@ -59,7 +59,7 @@ export const TemplateFilters = ({
|
||||
className={cn(
|
||||
selectedFilter[index] === filter.value
|
||||
? "bg-slate-800 font-semibold text-white"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{t(filter.label)}
|
||||
|
||||
@@ -46,10 +46,10 @@ export const Template = ({
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
|
||||
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
"group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-120 duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
)}>
|
||||
<TemplateTags template={template} selectedFilter={selectedFilter} />
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{template.description}</p>
|
||||
{activeTemplate?.name === template.name && (
|
||||
<div className="flex justify-start">
|
||||
|
||||
@@ -105,7 +105,7 @@ export const TemplateList = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-2 focus:outline-none">
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pt-2 pb-6 focus:outline-none">
|
||||
{showFilters && !templateSearch && (
|
||||
<TemplateFilters
|
||||
selectedFilter={selectedFilter}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
|
||||
<div className="inline-flex">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-br group-aria-expanded:rounded-bl-none">
|
||||
<PlusIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
@@ -68,7 +68,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
|
||||
onMouseEnter={() => setHoveredQuestionId(questionType.id)}
|
||||
onMouseLeave={() => setHoveredQuestionId(null)}>
|
||||
<div className="flex items-center">
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
<questionType.icon className="text-brand-dark mr-2 -ml-0.5 h-4 w-4" aria-hidden="true" />
|
||||
{questionType.label}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -167,7 +167,7 @@ export const EditEndingCard = ({
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-t border-b border-l py-2 group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">
|
||||
@@ -177,7 +177,7 @@ export const EditEndingCard = ({
|
||||
<Undo2 className="h-4 w-4 rotate-180" />
|
||||
)}
|
||||
</div>
|
||||
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
|
||||
<button className="opacity-0 transition-all duration-300 group-hover:opacity-100 hover:cursor-move">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export const EndScreenForm = ({
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
@@ -166,7 +166,7 @@ export const EndScreenForm = ({
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
|
||||
@@ -99,7 +99,7 @@ export const FileUploadQuestionForm = ({
|
||||
const removeExtension = (event, index: number) => {
|
||||
event.preventDefault();
|
||||
if (question.allowedFileExtensions) {
|
||||
const updatedExtensions = [...(question.allowedFileExtensions || [])];
|
||||
const updatedExtensions = [...question?.allowedFileExtensions];
|
||||
updatedExtensions.splice(index, 1);
|
||||
// Ensure array is set to undefined if empty, matching toggle behavior
|
||||
updateQuestion(questionIdx, {
|
||||
@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-8 mt-6 space-y-6">
|
||||
<div className="mt-6 mb-8 space-y-6">
|
||||
<AdvancedOptionToggle
|
||||
isChecked={question.allowMultipleFiles}
|
||||
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
|
||||
@@ -218,7 +218,7 @@ export const FileUploadQuestionForm = ({
|
||||
|
||||
updateQuestion(questionIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
|
||||
}}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
MB
|
||||
</p>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const FormStylingSettings = ({
|
||||
)}>
|
||||
<div className="inline-flex px-4 py-4">
|
||||
{!isSettingsPage && (
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
|
||||
@@ -113,7 +113,7 @@ export const HiddenFieldsCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@ export const HiddenFieldsCard = ({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="mt-2 text-sm italic text-slate-500">
|
||||
<p className="mt-2 text-sm text-slate-500 italic">
|
||||
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user