Compare commits

..

5 Commits

Author SHA1 Message Date
pandeymangg 1fba692626 fix: android sdk segment bug 2025-05-13 19:24:06 +05:30
pandeymangg 3ace91cdd5 fix: android sdk segment bug 2025-05-13 18:39:50 +05:30
pandeymangg 4ba7bf5b3c fix 2025-05-13 16:44:45 +05:30
pandeymangg bd1402a58b fixes android sdk issues: 2025-05-13 15:55:54 +05:30
pandeymangg c2af0c3fb6 fixes ios sdk issues and removes callbacks 2025-05-13 14:48:23 +05:30
188 changed files with 2132 additions and 1989 deletions
+2 -2
View File
@@ -211,5 +211,5 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Disable the user management from UI
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # DISABLE_USER_MANAGEMENT=1
+84
View 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
View File
@@ -10,11 +10,6 @@ jobs:
chromatic: chromatic:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+1 -1
View File
@@ -24,4 +24,4 @@ jobs:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review' - 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 tags: tag:github
- name: Configure AWS Credentials - 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: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"
+1
View File
@@ -25,6 +25,7 @@ permissions:
id-token: write id-token: write
contents: read contents: read
actions: read actions: read
checks: write
jobs: jobs:
build: build:
+4 -1
View File
@@ -20,15 +20,18 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
# This is used to complete the identity challenge # This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write
outputs: outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }} VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
description: "The version of the Helm chart to release" description: 'The version of the Helm chart to release'
required: true required: true
type: string type: string
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
revert revert
ossgg 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 # When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message. # condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null) 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 # Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - 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: with:
header: pr-title-lint-error header: pr-title-lint-error
message: | message: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
run: | run: |
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+10 -8
View File
@@ -1,8 +1,8 @@
name: "Terraform" name: 'Terraform'
on: on:
workflow_dispatch: workflow_dispatch:
# TODO: enable it back when migration is completed. # TODO: enable it back when migration is completed.
push: push:
branches: branches:
- main - main
@@ -14,13 +14,14 @@ on:
paths: paths:
- "infra/terraform/**" - "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs: jobs:
terraform: terraform:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
@@ -40,7 +41,7 @@ jobs:
tags: tag:github tags: tag:github
- name: Configure AWS Credentials - 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: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"
@@ -70,7 +71,7 @@ jobs:
working-directory: infra/terraform working-directory: infra/terraform
- name: Post PR comment - 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') if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with: with:
token: ${{ github.token }} token: ${{ github.token }}
@@ -82,3 +83,4 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile run: terraform apply .planfile
working-directory: "infra/terraform" working-directory: "infra/terraform"
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <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" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <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" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo" alt="Logo"
width={256} width={256}
height={56} 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> <p className="text-sm text-slate-400">{t("common.preview")}</p>
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div> </div>
</div> </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)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <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" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="Surveys">{t("common.questions")}</Label> <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"> <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) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">
@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <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"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (
@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button <button
type="button" type="button"
aria-label="Expand response" 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}> onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" /> <Maximize2Icon className="h-4 w-4" />
</button> </button>
@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <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) => { {summaryItems.map((summaryItem) => {
return ( return (
<button <button
@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
</div> </div>
)} )}
</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)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return ( return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> <div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer"> <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"> <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" /> <DownloadIcon className="h-6 text-slate-500" />
</div> </div>
@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <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"}> <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> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div> </div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div> </div>
)} )}
</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} {response.value}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <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"> <table className="mx-auto border-collapse cursor-default text-left">
<thead> <thead>
<tr> <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) => ( {columns.map((column) => (
<th key={column} className="text-center font-medium"> <th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody> <tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}> <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}> <TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p> <p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer> </TooltipRenderer>
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
) : undefined ) : 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) => ( {results.map((result, resultsIdx) => (
<Fragment key={result.value}> <Fragment key={result.value}>
<button <button
@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <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) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" 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" : ""}`}> 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"> <div className="mr-8 flex space-x-1">
<p <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} {group}
</p> </p>
<div> <div>
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))} ))}
</div> </div>
<div className="flex justify-center pb-4 pt-4"> <div className="flex justify-center pt-4 pb-4">
<HalfCircle value={questionSummary.score} /> <HalfCircle value={questionSummary.score} />
</div> </div>
</div> </div>
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined ) : 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) => ( {results.map((result, index) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" 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); const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
return ( 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"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl"> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes( {formatTextWithSlashes(
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div> </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) => ( {questionSummary.choices.map((result) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)} )}
</p> </p>
</div> </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"} {quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div> </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"> <div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span> <span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span> <span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
} catch (error) { } catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code")); toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
} }
}, [surveyUrl, t]); }, [surveyUrl]);
const downloadQRCode = () => { const downloadQRCode = () => {
try { try {
@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId); throw new ResourceNotFoundError("Organization not found", organizationId);
} }
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) { if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
} }
@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose(); value && handleDatePickerClose();
}}> }}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none"> <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"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span> <span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" /> <ArrowDownToLineIcon className="ml-2 h-4 w-4" />
@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`} key={`${o}-${index}`}
type="button" type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))} 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} {o}
<X width={14} height={14} className="ml-2" /> <X width={14} height={14} className="ml-2" />
</button> </button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
className={clsx( 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" !disabled ? "cursor-pointer" : "opacity-50"
)}> )}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject); 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(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
}); });
@@ -99,8 +99,12 @@ export const getEnvironmentState = async (
getActionClassesForEnvironmentState(environmentId), getActionClassesForEnvironmentState(environmentId),
]); ]);
const filteredSurveys = surveys.filter(
(survey) => survey.type === "app" && survey.status === "inProgress"
);
const data: TJsEnvironmentState["data"] = { const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? surveys : [], surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
actionClasses, actionClasses,
project: project, project: project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
@@ -100,11 +100,7 @@ describe("getSurveysForEnvironmentState", () => {
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { where: { environmentId },
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), // Check if select is called, specific fields are in the original code select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 30, take: 30,
@@ -120,11 +116,7 @@ describe("getSurveysForEnvironmentState", () => {
const result = await getSurveysForEnvironmentState(environmentId); const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { where: { environmentId },
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), select: expect.any(Object),
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 30, take: 30,
@@ -20,8 +20,6 @@ export const getSurveysForEnvironmentState = reactCache(
const surveysPrisma = await prisma.survey.findMany({ const surveysPrisma = await prisma.survey.findMany({
where: { where: {
environmentId, environmentId,
type: "app",
status: "inProgress",
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
@@ -57,10 +57,6 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId); return handleDatabaseError(error, request.url, endpoint, responseId);
} }
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId // get survey to get environmentId
let survey; let survey;
try { 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 { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client"; import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; 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 = { const mockSurvey: TSurvey = {
id: "survey-1", id: "survey-1",
createdAt: new Date(), createdAt: new Date(),
@@ -214,119 +206,4 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeNull(); 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 { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/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 { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types"; 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 (survey.recaptcha?.enabled) {
if (!responseInput.recaptchaToken) { if (!responseInput.recaptchaToken) {
logger.error("Missing recaptcha token"); logger.error("Missing recaptcha token");
+3 -12
View File
@@ -150,12 +150,7 @@ export const createActionClass = async (
...actionClassInput, ...actionClassInput,
environment: { connect: { id: environmentId } }, environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined, key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
}, },
select: selectActionClass, select: selectActionClass,
}); });
@@ -198,12 +193,7 @@ export const updateActionClass = async (
...actionClassInput, ...actionClassInput,
environment: { connect: { id: environmentId } }, environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined, key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
}, },
select: { select: {
...selectActionClass, ...selectActionClass,
@@ -222,6 +212,7 @@ export const updateActionClass = async (
id: result.id, id: result.id,
}); });
// @ts-expect-error
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId); const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
for (const surveyId of surveyIds) { for (const surveyId of surveyIds) {
surveyCache.revalidate({ surveyCache.revalidate({
+1 -1
View File
@@ -282,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; 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";
+2 -2
View File
@@ -104,7 +104,7 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "production", "test"]).optional(), NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(), PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).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, NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED, PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT, 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,
}, },
}); });
-18
View File
@@ -13,21 +13,3 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
isMember, 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;
};
+8 -2
View File
@@ -1,6 +1,12 @@
import structuredClonePolyfill from "@ungap/structured-clone"; import structuredClonePolyfill from "@ungap/structured-clone";
const structuredCloneExport = let structuredCloneExport: typeof structuredClonePolyfill;
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
if (typeof structuredClone === "undefined") {
structuredCloneExport = structuredClonePolyfill;
} else {
// @ts-expect-error
structuredCloneExport = structuredClone;
}
export { structuredCloneExport as structuredClone }; export { structuredCloneExport as structuredClone };
-1
View File
@@ -533,7 +533,6 @@ export const updateResponse = async (
id: response.id, id: response.id,
contactId: response.contact?.id, contactId: response.contact?.id,
surveyId: response.surveyId, surveyId: response.surveyId,
...(response.singleUseId ? { singleUseId: response.singleUseId } : {}),
}); });
responseNoteCache.revalidate({ responseNoteCache.revalidate({
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export const useIntervalWhenFocused = ( export const useIntervalWhenFocused = (
callback: () => void, callback: () => void,
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
) => { ) => {
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const handleFocus = useCallback(() => { const handleFocus = () => {
if (isActive) { if (isActive) {
if (shouldExecuteImmediately) { if (shouldExecuteImmediately) {
// Execute the callback immediately when the tab comes into focus // Execute the callback immediately when the tab comes into focus
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
callback(); callback();
}, intervalDuration); }, intervalDuration);
} }
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]); };
const handleBlur = () => { const handleBlur = () => {
// Clear the interval when the tab loses focus // Clear the interval when the tab loses focus
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
window.removeEventListener("focus", handleFocus); window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur); window.removeEventListener("blur", handleBlur);
}; };
}, [isActive, intervalDuration, handleFocus]); }, [isActive, intervalDuration]);
}; };
export default useIntervalWhenFocused; export default useIntervalWhenFocused;
+26 -1
View File
@@ -12,12 +12,13 @@ import {
isClientSideApiRoute, isClientSideApiRoute,
isForgotPasswordRoute, isForgotPasswordRoute,
isLoginRoute, isLoginRoute,
isManagementApiRoute,
isShareUrlRoute, isShareUrlRoute,
isSignupRoute, isSignupRoute,
isSyncWithUserIdentificationEndpoint, isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute, isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator"; } 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 { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiError } from "@/modules/api/v2/lib/utils"; import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; 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 { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger"; 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 handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any }); 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 // Handle authentication
const authResponse = await handleAuth(request); const authResponse = await handleAuth(request);
if (authResponse) return authResponse; if (authResponse) return authResponse;
@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<Input <Input
data-testid="survey-url-input" data-testid="survey-url-input"
autoFocus={true} 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} value={surveyUrl}
/> />
) : ( ) : (
@@ -39,7 +39,7 @@ export const QuestionSkip = ({
background: background:
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design "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>
} }
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</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 ( return (
<p <p
key={rowValueInSelectedLanguage} 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])} {rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p> </p>
); );
@@ -104,10 +104,10 @@ export const ResponseNotes = ({
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3", !isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50", !isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen 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 : unresolvedNotes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12" ? "top-[8.33%] right-0 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.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)} )}
onClick={() => { onClick={() => {
if (!isOpen) setIsOpen(true); if (!isOpen) setIsOpen(true);
@@ -116,7 +116,7 @@ export const ResponseNotes = ({
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div <div
className={clsx( 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 ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}> )}>
{!unresolvedNotes.length ? ( {!unresolvedNotes.length ? (
@@ -127,7 +127,7 @@ export const ResponseNotes = ({
</div> </div>
) : ( ) : (
<div className="float-left mr-1.5"> <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>
)} )}
</div> </div>
@@ -141,7 +141,7 @@ export const ResponseNotes = ({
</div> </div>
) : ( ) : (
<div className="relative flex h-full flex-col"> <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="flex items-center justify-between">
<div className="group flex items-center"> <div className="group flex items-center">
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3> <h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>
@@ -37,7 +37,7 @@ export const SingleResponseCardBody = ({
return ( return (
<span <span
key={index} 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} @{part}
</span> </span>
); );
@@ -153,7 +153,7 @@ export const SingleResponseCardHeader = ({
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>; const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
return ( 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-between">
<div className="flex items-center justify-center space-x-4"> <div className="flex items-center justify-center space-x-4">
{pageType === "response" && ( {pageType === "response" && (
@@ -111,7 +111,6 @@ export const updateResponse = async (
responseCache.revalidate({ responseCache.revalidate({
id: updatedResponse.id, id: updatedResponse.id,
surveyId: updatedResponse.surveyId, surveyId: updatedResponse.surveyId,
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
}); });
responseNoteCache.revalidate({ responseNoteCache.revalidate({
@@ -1,5 +1,4 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock"; import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { responseCache } from "@/lib/response/cache";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
@@ -22,16 +21,6 @@ vi.mock("../utils", () => ({
findAndDeleteUploadedFilesInResponse: vi.fn(), findAndDeleteUploadedFilesInResponse: vi.fn(),
})); }));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
byResponseId: vi.fn(),
},
},
}));
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
response: { response: {
@@ -186,7 +175,7 @@ describe("Response Lib", () => {
}); });
describe("updateResponse", () => { 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); vi.mocked(prisma.response.update).mockResolvedValue(response);
const result = await updateResponse(responseId, responseInput); const result = await updateResponse(responseId, responseInput);
@@ -195,39 +184,12 @@ describe("Response Lib", () => {
data: responseInput, data: responseInput,
}); });
expect(responseCache.revalidate).toHaveBeenCalledWith({
id: response.id,
surveyId: response.surveyId,
singleUseId: response.singleUseId,
});
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (result.ok) { if (result.ok) {
expect(result.data).toEqual(response); 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 () => { test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue( vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", { new PrismaClientKnownRequestError("Response not found", {
@@ -14,7 +14,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
return ( return (
<FormWrapper> <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")} {t("auth.verification-requested.please_confirm_your_email_address")}
</h1> </h1>
<p className="text-center text-sm text-slate-700"> <p className="text-center text-sm text-slate-700">
@@ -19,7 +19,7 @@ export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrim
return ( return (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} 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}> {...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300"> <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
<div <div
@@ -126,7 +126,7 @@ export const PricingCard = ({
id={plan.id} id={plan.id}
className={cn( className={cn(
plan.featured ? "text-slate-900" : "text-slate-800", plan.featured ? "text-slate-900" : "text-slate-800",
"text-sm font-semibold leading-6" "text-sm leading-6 font-semibold"
)}> )}>
{t(plan.name)} {t(plan.name)}
</h2> </h2>
@@ -41,7 +41,7 @@ export const SingleContactPage = async (props: {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} /> <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"> <div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} /> <AttributesSection contactId={params.contactId} />
<ResponseSection <ResponseSection
@@ -229,52 +229,51 @@ export const upsertBulkContacts = async (
try { try {
// Execute everything in ONE transaction // Execute everything in ONE transaction
await prisma.$transaction( await prisma.$transaction(async (tx) => {
async (tx) => { const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => {
const attributeKeyMap = existingAttributeKeys.reduce<Record<string, string>>((acc, keyObj) => { acc[keyObj.key] = keyObj.id;
acc[keyObj.key] = keyObj.id; return acc;
return acc; }, {});
}, {});
// Check for missing attribute keys and create them if needed. // Check for missing attribute keys and create them if needed.
const missingKeysMap = new Map<string, { key: string; name: string }>(); const missingKeysMap = new Map<string, { key: string; name: string }>();
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>(); const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
for (const contact of filteredContacts) { for (const contact of filteredContacts) {
for (const attr of contact.attributes) { for (const attr of contact.attributes) {
if (!attributeKeyMap[attr.attributeKey.key]) { if (!attributeKeyMap[attr.attributeKey.key]) {
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey); missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
} else { } else {
// Check if the name has changed for existing attribute keys // Check if the name has changed for existing attribute keys
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key); const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
if (existingKey && existingKey.name !== attr.attributeKey.name) { if (existingKey && existingKey.name !== attr.attributeKey.name) {
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey); attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
}
} }
} }
} }
}
// Handle both missing keys and name updates in a single batch operation // Handle both missing keys and name updates in a single batch operation
const keysToUpsert = new Map<string, { key: string; name: string }>(); const keysToUpsert = new Map<string, { key: string; name: string }>();
// Collect all keys that need to be created or updated // Collect all keys that need to be created or updated
for (const [key, value] of missingKeysMap) { for (const [key, value] of missingKeysMap) {
keysToUpsert.set(key, value); keysToUpsert.set(key, value);
} }
for (const [key, value] of attributeKeyNameUpdates) { for (const [key, value] of attributeKeyNameUpdates) {
keysToUpsert.set(key, value); keysToUpsert.set(key, value);
} }
if (keysToUpsert.size > 0) { if (keysToUpsert.size > 0) {
const keysArray = Array.from(keysToUpsert.values()); const keysArray = Array.from(keysToUpsert.values());
const BATCH_SIZE = 10000; const BATCH_SIZE = 10000;
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) { for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
const batch = keysArray.slice(i, i + BATCH_SIZE); const batch = keysArray.slice(i, i + BATCH_SIZE);
// Use raw query to perform upsert // Use raw query to perform upsert
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>` const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at") INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
SELECT SELECT
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}), unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
@@ -290,59 +289,59 @@ export const upsertBulkContacts = async (
RETURNING "id", "key" RETURNING "id", "key"
`; `;
// Update attribute key map with upserted keys // Update attribute key map with upserted keys
for (const key of upsertedKeys) { for (const key of upsertedKeys) {
attributeKeyMap[key.key] = key.id; attributeKeyMap[key.key] = key.id;
}
} }
} }
}
// Create new contacts -- should be at most 1000, no need to batch // Create new contacts -- should be at most 1000, no need to batch
const newContacts = contactsToCreate.map(() => ({ 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(), 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) { const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
await tx.contact.createMany({ contact.attributes.map((attr) => ({
data: newContacts, 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 attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
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 attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) => // Skip the raw query if there are no attributes to upsert
contact.attributes.map((attr) => ({ if (attributesToUpsert.length > 0) {
id: attr.id, // Process attributes in batches of 10,000
contactId: contact.contactId, const BATCH_SIZE = 10000;
attributeKeyId: attributeKeyMap[attr.attributeKey.key], for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
value: attr.value, const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
createdAt: attr.createdAt,
updatedAt: new Date(),
}))
);
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers]; // Use a raw query to perform a bulk insert with an ON CONFLICT clause
await tx.$executeRaw`
// 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`
INSERT INTO "ContactAttribute" ( INSERT INTO "ContactAttribute" (
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId" "id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
) )
@@ -357,37 +356,33 @@ export const upsertBulkContacts = async (
"value" = EXCLUDED."value", "value" = EXCLUDED."value",
"updated_at" = EXCLUDED."updated_at" "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({ return ok({
contactIdxWithConflictingUserIds, contactIdxWithConflictingUserIds,
@@ -312,7 +312,7 @@ function AttributeSegmentFilter({
}} }}
value={attrKeyValue}> value={attrKeyValue}>
<SelectTrigger <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> hideArrow>
<SelectValue> <SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}> <div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -494,7 +494,7 @@ function PersonSegmentFilter({
}} }}
value={personIdentifier}> value={personIdentifier}>
<SelectTrigger <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> hideArrow>
<SelectValue> <SelectValue>
<div className="flex items-center gap-1 lowercase"> <div className="flex items-center gap-1 lowercase">
@@ -643,7 +643,7 @@ function SegmentSegmentFilter({
}} }}
value={currentSegment?.id}> value={currentSegment?.id}>
<SelectTrigger <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> hideArrow>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" /> <Users2Icon className="h-4 w-4 text-sm" />
@@ -171,7 +171,7 @@ export function TargetingCard({
asChild asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"> className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6"> <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 <CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600" className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
strokeWidth={3} strokeWidth={3}
@@ -126,7 +126,7 @@ export const ZContactBulkUploadRequest = z.object({
environmentId: z.string().cuid2(), environmentId: z.string().cuid2(),
contacts: z contacts: z
.array(ZContactBulkUploadContact) .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) => { .superRefine((contacts, ctx) => {
// Track all data in a single pass // Track all data in a single pass
const seenEmails = new Set<string>(); 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")} {t("environments.project.languages.no_language_found")}
</p> </p>
)} )}
@@ -44,7 +44,7 @@ export function LanguageIndicator({
}); });
return ( return (
<div className="absolute right-2 top-2"> <div className="absolute top-2 right-2">
<button <button
aria-expanded={showLanguageDropdown} aria-expanded={showLanguageDropdown}
aria-haspopup="true" aria-haspopup="true"
@@ -65,7 +65,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</Button> </Button>
<div <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 <Input
autoComplete="off" autoComplete="off"
onChange={(e) => { onChange={(e) => {
@@ -186,7 +186,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div <div
className={cn( className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50", 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> <p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" /> <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 && ( {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 {projectLanguages.length === 0
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started") ? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
: t( : t(
@@ -260,7 +260,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div className="my-4 space-y-4"> <div className="my-4 space-y-4">
<div> <div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? ( {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")} {t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div> </div>
) : null} ) : null}
@@ -15,14 +15,14 @@ import { AuthenticationError, OperationNotAllowedError, ValidationError } from "
// Mock constants with getter functions to allow overriding in tests // Mock constants with getter functions to allow overriding in tests
let mockIsFormbricksCloud = false; let mockIsFormbricksCloud = false;
let mockUserManagementMinimumRole = "owner"; let mockDisableUserManagement = false;
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() { get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud; return mockIsFormbricksCloud;
}, },
get USER_MANAGEMENT_MINIMUM_ROLE() { get DISABLE_USER_MANAGEMENT() {
return mockUserManagementMinimumRole; return mockDisableUserManagement;
}, },
})); }));
@@ -62,7 +62,7 @@ describe("Role Management Actions", () => {
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
mockIsFormbricksCloud = false; mockIsFormbricksCloud = false;
mockUserManagementMinimumRole = "owner"; mockDisableUserManagement = false;
}); });
describe("checkRoleManagementPermission", () => { describe("checkRoleManagementPermission", () => {
@@ -220,7 +220,7 @@ describe("Role Management Actions", () => {
test("throws error if user management is disabled", async () => { test("throws error if user management is disabled", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockUserManagementMinimumRole = "disabled"; mockDisableUserManagement = true;
await expect( await expect(
updateMembershipAction({ updateMembershipAction({
@@ -231,12 +231,12 @@ describe("Role Management Actions", () => {
data: { role: "member" }, data: { role: "member" },
}, },
} as any) } 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 () => { test("throws error if billing role is not allowed in self-hosted", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockUserManagementMinimumRole = "owner"; mockDisableUserManagement = false;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
await expect( await expect(
@@ -253,7 +253,7 @@ describe("Role Management Actions", () => {
test("allows billing role in cloud environment", async () => { test("allows billing role in cloud environment", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockUserManagementMinimumRole = "owner"; mockDisableUserManagement = false;
mockIsFormbricksCloud = true; mockIsFormbricksCloud = true;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any); 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 () => { test("throws error if manager tries to assign a role other than member", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
mockUserManagementMinimumRole = "manager"; mockDisableUserManagement = false;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
await expect( await expect(
@@ -291,7 +291,7 @@ describe("Role Management Actions", () => {
test("allows manager to assign member role", async () => { test("allows manager to assign member role", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
mockUserManagementMinimumRole = "manager"; mockDisableUserManagement = false;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any); vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true); vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
@@ -312,7 +312,7 @@ describe("Role Management Actions", () => {
test("successful membership update as owner", async () => { test("successful membership update as owner", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockUserManagementMinimumRole = "owner"; mockDisableUserManagement = false;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any); vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true); vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
@@ -1,8 +1,7 @@
"use server"; "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 { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
@@ -88,13 +87,8 @@ export const updateMembershipAction = authenticatedActionClient
if (!currentUserMembership) { if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization"); throw new AuthenticationError("User not a member of this organization");
} }
const hasUserManagementAccess = getUserManagementAccess( if (DISABLE_USER_MANAGEMENT) {
currentUserMembership.role, throw new OperationNotAllowedError("User management is disabled");
USER_MANAGEMENT_MINIMUM_ROLE
);
if (!hasUserManagementAccess) {
throw new OperationNotAllowedError("User management is not allowed for your role");
} }
await checkAuthorizationUpdated({ 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,
};
@@ -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"> <div className="mb-10">
<Small>{t("environments.settings.general.logo_in_email_header")}</Small> <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 && ( {logoUrl && (
<div className="flex flex-col gap-2"> <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"> <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> </Button>
</div> </div>
</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 <Image
data-testid="email-customization-preview-image" data-testid="email-customization-preview-image"
src={logoUrl || fbLogoUrl} src={logoUrl || fbLogoUrl}
@@ -284,7 +284,7 @@ export const EmailCustomizationSettings = ({
)} )}
{hasWhiteLabelPermission && isReadOnly && ( {hasWhiteLabelPermission && isReadOnly && (
<Alert variant="warning" className="mb-6 mt-4"> <Alert variant="warning" className="mt-4 mb-6">
<AlertDescription> <AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription> </AlertDescription>
@@ -10,11 +10,11 @@ interface QuestionHeaderProps {
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element { export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
return ( 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} {headline}
</Text> </Text>
{subheader && ( {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: case TSurveyQuestionTypeEnum.Consent:
return ( return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}> <EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text> <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 font-normal leading-6"> <Container className="text-question-color m-0 text-sm leading-6 font-normal">
<div <div
className="m-0 p-0" className="m-0 p-0"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -131,7 +131,7 @@ export async function PreviewEmailTemplate({
)}> )}>
{firstQuestion.isColorCodingEnabled ? ( {firstQuestion.isColorCodingEnabled ? (
<Section <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} ) : null}
{i} {i}
@@ -162,8 +162,8 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.CTA: case TSurveyQuestionTypeEnum.CTA:
return ( return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}> <EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text> <Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6"> <Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
<div <div
className="m-0 p-0" className="m-0 p-0"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -227,7 +227,7 @@ export async function PreviewEmailTemplate({
<> <>
{firstQuestion.isColorCodingEnabled ? ( {firstQuestion.isColorCodingEnabled ? (
<Section <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} ) : null}
<Text className="m-0 flex h-10 items-center">{i + 1}</Text> <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.choices.map((choice) =>
firstQuestion.allowMulti ? ( firstQuestion.allowMulti ? (
<Img <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} key={choice.id}
src={choice.imageUrl} src={choice.imageUrl}
/> />
) : ( ) : (
<Link <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}`} href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id} key={choice.id}
target="_blank"> target="_blank">
@@ -369,11 +369,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0"> <Container className="mx-0">
<Section className="w-full table-auto"> <Section className="w-full table-auto">
<Row> <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) => { {firstQuestion.columns.map((column) => {
return ( return (
<Column <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")}> key={getLocalizedValue(column, "default")}>
{getLocalizedValue(column, "default")} {getLocalizedValue(column, "default")}
</Column> </Column>
@@ -385,7 +385,7 @@ export async function PreviewEmailTemplate({
<Row <Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`} className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={getLocalizedValue(row, "default")}> 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")} {getLocalizedValue(row, "default")}
</Column> </Column>
{firstQuestion.columns.map((_) => { {firstQuestion.columns.map((_) => {
+2 -2
View File
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return ( return (
<Container> <Container>
{overrideFileUploadResponse ? ( {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")} {t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text> </Text>
) : ( ) : (
@@ -66,6 +66,6 @@ export const renderEmailResponseValue = async (
); );
default: 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} {variable.name}
</Text> </Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-bold"> <Text className="mt-0 font-bold break-words whitespace-pre-wrap">
{variableResponse} {variableResponse}
</Text> </Text>
</Column> </Column>
@@ -84,7 +84,7 @@ export async function ResponseFinishedEmail({
<Text className="mb-2 flex items-center gap-2 font-medium"> <Text className="mb-2 flex items-center gap-2 font-medium">
{hiddenFieldId} <EyeOffIcon /> {hiddenFieldId} <EyeOffIcon />
</Text> </Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-bold"> <Text className="mt-0 font-bold break-words whitespace-pre-wrap">
{hiddenFieldResponse} {hiddenFieldResponse}
</Text> </Text>
</Column> </Column>
@@ -90,7 +90,7 @@ export const WebhookRowData = ({
<div className="col-span-2 my-auto text-center text-sm text-slate-800"> <div className="col-span-2 my-auto text-center text-sm text-slate-800">
{renderSelectedTriggersText(webhook, t)} {renderSelectedTriggersText(webhook, t)}
</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(webhook.createdAt.toString(), locale)} {timeSince(webhook.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>
@@ -162,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
</div> </div>
<div className="grid-cols-9"> <div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? ( {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")} {t("environments.project.api_keys.no_api_keys_yet")}
</div> </div>
) : ( ) : (
@@ -10,7 +10,7 @@ const LoadingCard = () => {
return ( return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm"> <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"> <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> <span className="sr-only">{t("common.loading")}</span>
</h3> </h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"> <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"> <div className="sticky top-0 flex h-full flex-col rounded-lg">
<button <button
className={cn( 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={() => { onClick={() => {
setOpen(false); setOpen(false);
@@ -13,7 +13,7 @@ vi.mock(
); );
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
USER_MANAGEMENT_MINIMUM_ROLE: "owner", DISABLE_USER_MANAGEMENT: 0,
IS_FORMBRICKS_CLOUD: 1, IS_FORMBRICKS_CLOUD: 1,
ENCRYPTION_KEY: "test-key", ENCRYPTION_KEY: "test-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-key", ENTERPRISE_LICENSE_KEY: "test-enterprise-key",
@@ -1,6 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants"; import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view"; import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; 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 { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -37,7 +32,7 @@ export const TeamsPage = async (props) => {
currentUserId={session.user.id} currentUserId={session.user.id}
environmentId={params.environmentId} environmentId={params.environmentId}
canDoRoleManagement={canDoRoleManagement} canDoRoleManagement={canDoRoleManagement}
isUserManagementDisabledFromUi={!hasUserManagementAccess} isUserManagementDisabledFromUi={DISABLE_USER_MANAGEMENT}
/> />
<TeamsView <TeamsView
organizationId={organization.id} 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 className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div> </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")} {t("common.loading")}
</Button> </Button>
</div> </div>
@@ -159,7 +159,7 @@ export const ProjectLookSettingsLoading = () => {
title="Formbricks Signature" title="Formbricks Signature"
description="We love your support but understand if you toggle it off."> description="We love your support but understand if you toggle it off.">
<div className="w-full items-center"> <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} /> <Switch id="signature" checked={false} />
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label> <Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
</div> </div>
@@ -99,12 +99,12 @@ export const SingleTag: React.FC<SingleTagProps> = ({
</div> </div>
</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 className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div> </div>
{!isReadOnly && ( {!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> <div>
{isMergingTags ? ( {isMergingTags ? (
<div className="w-24"> <div className="w-24">
@@ -139,7 +139,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
<Button <Button
variant="destructive" variant="destructive"
size="sm" 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)}> onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")} {t("common.delete")}
</Button> </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) => { {filteredRecallItems.map((recallItem, index) => {
const IconComponent = getRecallItemIcon(recallItem); const IconComponent = getRecallItemIcon(recallItem);
return ( return (
@@ -201,7 +201,7 @@ export const RecallItemSelect = ({
} }
}}> }}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div> <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)} {getRecallLabel(recallItem.label)}
</p> </p>
</DropdownMenuItem> </DropdownMenuItem>
@@ -220,7 +220,7 @@ export const RecallWrapper = ({
} }
parts.push( parts.push(
<span <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}`}> key={`recall-${parts.length}`}>
{"@" + label} {"@" + label}
</span> </span>
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
<Button <Button
variant="ghost" variant="ghost"
type="button" 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) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setShowFallbackInput(true); setShowFallbackInput(true);
@@ -271,7 +271,7 @@ export const QuestionFormInput = ({
return ( return (
<div className="w-full"> <div className="w-full">
{label && ( {label && (
<div className="mb-2 mt-3"> <div className="mt-3 mb-2">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
</div> </div>
)} )}
@@ -342,7 +342,7 @@ export const QuestionFormInput = ({
<div className="h-10 w-full"></div> <div className="h-10 w-full"></div>
<div <div
ref={highlightContainerRef} 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" : "" localSurvey.languages?.length > 1 ? "pr-24" : ""
}`} }`}
dir="auto" dir="auto"
@@ -45,10 +45,10 @@ export const StartFromScratchTemplate = ({
activeTemplate?.name === customSurvey.name activeTemplate?.name === customSurvey.name
? "ring-brand-dark border-transparent ring-2" ? "ring-brand-dark border-transparent ring-2"
: "hover:border-brand-dark border-dashed border-slate-300", : "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" /> <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> <p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
{activeTemplate?.name === customSurvey.name && ( {activeTemplate?.name === customSurvey.name && (
<div className="text-left"> <div className="text-left">
@@ -41,7 +41,7 @@ export const TemplateFilters = ({
className={cn( className={cn(
selectedFilter[index] === null selectedFilter[index] === null
? "bg-slate-800 font-semibold text-white" ? "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" "rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}> )}>
{index === 0 {index === 0
@@ -59,7 +59,7 @@ export const TemplateFilters = ({
className={cn( className={cn(
selectedFilter[index] === filter.value selectedFilter[index] === filter.value
? "bg-slate-800 font-semibold text-white" ? "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" "rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}> )}>
{t(filter.label)} {t(filter.label)}
@@ -46,10 +46,10 @@ export const Template = ({
key={template.name} key={template.name}
className={cn( className={cn(
activeTemplate?.name === template.name && "ring-2 ring-slate-400", 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} /> <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> <p className="text-left text-xs text-slate-600">{template.description}</p>
{activeTemplate?.name === template.name && ( {activeTemplate?.name === template.name && (
<div className="flex justify-start"> <div className="flex justify-start">
@@ -105,7 +105,7 @@ export const TemplateList = ({
}; };
return ( 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 && ( {showFilters && !templateSearch && (
<TemplateFilters <TemplateFilters
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
@@ -38,7 +38,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
)}> )}>
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full"> <Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
<div className="inline-flex"> <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" /> <PlusIcon className="h-5 w-5 text-white" />
</div> </div>
<div className="px-4 py-3"> <div className="px-4 py-3">
@@ -68,7 +68,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
onMouseEnter={() => setHoveredQuestionId(questionType.id)} onMouseEnter={() => setHoveredQuestionId(questionType.id)}
onMouseLeave={() => setHoveredQuestionId(null)}> onMouseLeave={() => setHoveredQuestionId(null)}>
<div className="flex items-center"> <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} {questionType.label}
</div> </div>
<div <div
@@ -87,6 +87,21 @@ export const CTAQuestionForm = ({
<div className="mt-2 flex justify-between gap-8"> <div className="mt-2 flex justify-between gap-8">
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
{questionIdx !== 0 && ( {questionIdx !== 0 && (
<QuestionFormInput <QuestionFormInput
id="backButtonLabel" id="backButtonLabel"
@@ -103,20 +118,6 @@ export const CTAQuestionForm = ({
locale={locale} locale={locale}
/> />
)} )}
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
label={t("environments.surveys.edit.next_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
</div> </div>
</div> </div>
@@ -167,7 +167,7 @@ export const EditEndingCard = ({
{...attributes} {...attributes}
className={cn( className={cn(
open ? "bg-slate-50" : "", 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" isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}> )}>
<div className="mt-3 flex w-full justify-center"> <div className="mt-3 flex w-full justify-center">
@@ -177,7 +177,7 @@ export const EditEndingCard = ({
<Undo2 className="h-4 w-4 rotate-180" /> <Undo2 className="h-4 w-4 rotate-180" />
)} )}
</div> </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" /> <GripIcon className="h-4 w-4" />
</button> </button>
</div> </div>
@@ -123,7 +123,7 @@ export const EndScreenForm = ({
</Label> </Label>
</div> </div>
{showEndingCardCTA && ( {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"> <div className="space-y-2">
<QuestionFormInput <QuestionFormInput
id="buttonLabel" id="buttonLabel"
@@ -166,7 +166,7 @@ export const EndScreenForm = ({
<div className="group relative"> <div className="group relative">
{/* The highlight container is absolutely positioned behind the input */} {/* The highlight container is absolutely positioned behind the input */}
<div <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" dir="auto"
key={highlightedJSX.toString()}> key={highlightedJSX.toString()}>
{highlightedJSX} {highlightedJSX}
@@ -99,7 +99,7 @@ export const FileUploadQuestionForm = ({
const removeExtension = (event, index: number) => { const removeExtension = (event, index: number) => {
event.preventDefault(); event.preventDefault();
if (question.allowedFileExtensions) { if (question.allowedFileExtensions) {
const updatedExtensions = [...(question.allowedFileExtensions || [])]; const updatedExtensions = [...question?.allowedFileExtensions];
updatedExtensions.splice(index, 1); updatedExtensions.splice(index, 1);
// Ensure array is set to undefined if empty, matching toggle behavior // Ensure array is set to undefined if empty, matching toggle behavior
updateQuestion(questionIdx, { updateQuestion(questionIdx, {
@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
</Button> </Button>
)} )}
</div> </div>
<div className="mb-8 mt-6 space-y-6"> <div className="mt-6 mb-8 space-y-6">
<AdvancedOptionToggle <AdvancedOptionToggle
isChecked={question.allowMultipleFiles} isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })} onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
@@ -218,7 +218,7 @@ export const FileUploadQuestionForm = ({
updateQuestion(questionIdx, { maxSizeInMB: parseInt(e.target.value, 10) }); 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 MB
</p> </p>
@@ -89,7 +89,7 @@ export const FormStylingSettings = ({
)}> )}>
<div className="inline-flex px-4 py-4"> <div className="inline-flex px-4 py-4">
{!isSettingsPage && ( {!isSettingsPage && (
<div className="flex items-center pl-2 pr-5"> <div className="flex items-center pr-5 pl-2">
<CheckIcon <CheckIcon
strokeWidth={3} strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600" className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

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