Compare commits

...

9 Commits

Author SHA1 Message Date
Matthias Nannt
a4811351be chore: improve js logging 2025-05-16 12:13:14 +02:00
Dhruwang Jariwala
9fcbe4e8c5 chore: swap next and back button input (#5748)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-16 08:51:12 +00:00
Piyush Gupta
5aeb92eb4f chore: removes https enforcement from management api (#5810) 2025-05-15 19:40:04 +00:00
Matti Nannt
00dfa629b5 fix: build process warnings (#5734)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-15 15:46:05 +00:00
Piyush Gupta
3ca471b6a2 feat: implement user management role configuration and access control (#5808) 2025-05-15 15:09:33 +00:00
Dhruwang Jariwala
a525589186 fix: token permisson issues (#4986) 2025-05-15 08:29:40 +00:00
Piyush Gupta
59ed10398d fix: suid bugs (#5780)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 12:09:41 +00:00
Piyush Gupta
25a86e31df fix: promise misuse error (#5783) 2025-05-14 12:06:41 +00:00
Matti Nannt
7d6743a81a chore(deps): upgrade npm dependencies in /packages (#5807) 2025-05-14 14:09:26 +02:00
145 changed files with 1465 additions and 1440 deletions

View File

@@ -211,5 +211,5 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"

View File

@@ -10,6 +10,11 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -25,7 +25,6 @@ permissions:
id-token: write
contents: read
actions: read
checks: write
jobs:
build:

View File

@@ -20,18 +20,15 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}

View File

@@ -4,7 +4,7 @@ on:
workflow_call:
inputs:
VERSION:
description: 'The version of the Helm chart to release'
description: "The version of the Helm chart to release"
required: true
type: string

View File

@@ -1,8 +1,8 @@
name: 'Terraform'
name: "Terraform"
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
# TODO: enable it back when migration is completed.
push:
branches:
- main
@@ -14,14 +14,13 @@ on:
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
@@ -83,4 +82,3 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-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>

View File

@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div>
</div>
</div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />

View File

@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}>
<div className="border-b border-slate-200 px-4 pb-4">
<div className="flex">
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button
type="button"
aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
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"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</button>

View File

@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<button

View File

@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">

View File

@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute top-0 right-0 m-2">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>

View File

@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">

View File

@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<table className="mx-auto border-collapse cursor-default text-left">
<thead>
<tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>

View File

@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
) : undefined
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button

View File

@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))}
</div>
<div className="flex justify-center pt-4 pb-4">
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"

View File

@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(

View File

@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div>
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"

View File

@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)}
</p>
</div>
<div className="text-center font-semibold whitespace-pre-wrap">
<div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>

View File

@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
}, [surveyUrl]);
}, [surveyUrl, t]);
const downloadQRCode = () => {
try {

View File

@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}

View File

@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<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="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="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />

View File

@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger
disabled={disabled}
className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
"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",
!disabled ? "cursor-pointer" : "opacity-50"
)}>
<div className="flex items-center justify-between">

View File

@@ -57,6 +57,10 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId
let survey;
try {

View File

@@ -3,6 +3,7 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -40,6 +41,13 @@ vi.mock("@formbricks/logger", () => ({
},
}));
vi.mock("@/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
const mockSurvey: TSurvey = {
id: "survey-1",
createdAt: new Date(),
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeNull();
});
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: {},
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url: "not-a-url" },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
);
});
test("should return badRequestResponse if suId is missing from url", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?foo=bar";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeNull();
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(_resultEncryptedMatch).toBeNull();
});
});

View File

@@ -2,6 +2,8 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
);
}
if (survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error.message,
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
}
if (survey.recaptcha?.enabled) {
if (!responseInput.recaptchaToken) {
logger.error("Missing recaptcha token");

View File

@@ -282,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";

View File

@@ -104,7 +104,7 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
},
/*
@@ -199,6 +199,6 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
},
});

View File

@@ -13,3 +13,21 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
isMember,
};
};
export const getUserManagementAccess = (
role: TOrganizationRole,
minimumRole: "owner" | "manager" | "disabled"
): boolean => {
// If minimum role is "disabled", no one has access
if (minimumRole === "disabled") {
return false;
}
if (minimumRole === "owner") {
return role === "owner";
}
if (minimumRole === "manager") {
return role === "owner" || role === "manager";
}
return false;
};

View File

@@ -533,6 +533,7 @@ export const updateResponse = async (
id: response.id,
contactId: response.contact?.id,
surveyId: response.surveyId,
...(response.singleUseId ? { singleUseId: response.singleUseId } : {}),
});
responseNoteCache.revalidate({

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
export const useIntervalWhenFocused = (
callback: () => void,
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
) => {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const handleFocus = () => {
const handleFocus = useCallback(() => {
if (isActive) {
if (shouldExecuteImmediately) {
// Execute the callback immediately when the tab comes into focus
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
callback();
}, intervalDuration);
}
};
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
const handleBlur = () => {
// Clear the interval when the tab loses focus
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, [isActive, intervalDuration]);
}, [isActive, intervalDuration, handleFocus]);
};
export default useIntervalWhenFocused;

View File

@@ -12,13 +12,12 @@ import {
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -28,24 +27,6 @@ import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
details: [
{
field: "",
issue: "Only HTTPS connections are allowed on the management endpoints.",
},
],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
}
return null;
};
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
@@ -132,12 +113,6 @@ export const middleware = async (originalRequest: NextRequest) => {
},
});
// Enforce HTTPS for management endpoints
if (isManagementApiRoute(request.nextUrl.pathname)) {
const httpsResponse = enforceHttps(request);
if (httpsResponse) return httpsResponse;
}
// Handle authentication
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;

View File

@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<Input
data-testid="survey-url-input"
autoFocus={true}
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl}
/>
) : (

View File

@@ -39,7 +39,7 @@ export const QuestionSkip = ({
background:
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
}}>
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
</div>
}
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>

View File

@@ -101,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
return (
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);

View File

@@ -104,10 +104,10 @@ export const ResponseNotes = ({
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
@@ -116,7 +116,7 @@ export const ResponseNotes = ({
<div className="flex h-full flex-col">
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pt-2 pb-2",
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!unresolvedNotes.length ? (
@@ -127,7 +127,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="float-left mr-1.5">
<Maximize2Icon className="h-4 w-4 text-amber-500 group-hover/hint:scale-110 hover:text-amber-600" />
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
</div>
)}
</div>
@@ -141,7 +141,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="relative flex h-full flex-col">
<div className="rounded-t-lg bg-amber-50 px-4 pt-4 pb-3">
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>

View File

@@ -37,7 +37,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
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">
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">
@{part}
</span>
);

View File

@@ -153,7 +153,7 @@ export const SingleResponseCardHeader = ({
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
return (
<div className="space-y-2 border-b border-slate-200 px-6 pt-4 pb-4">
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-4">
{pageType === "response" && (

View File

@@ -111,6 +111,7 @@ export const updateResponse = async (
responseCache.revalidate({
id: updatedResponse.id,
surveyId: updatedResponse.surveyId,
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
});
responseNoteCache.revalidate({

View File

@@ -1,4 +1,5 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { responseCache } from "@/lib/response/cache";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
@@ -21,6 +22,16 @@ vi.mock("../utils", () => ({
findAndDeleteUploadedFilesInResponse: vi.fn(),
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
byResponseId: vi.fn(),
},
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
@@ -175,7 +186,7 @@ describe("Response Lib", () => {
});
describe("updateResponse", () => {
test("update the response and revalidate caches", async () => {
test("update the response and revalidate caches including singleUseId", async () => {
vi.mocked(prisma.response.update).mockResolvedValue(response);
const result = await updateResponse(responseId, responseInput);
@@ -184,12 +195,39 @@ describe("Response Lib", () => {
data: responseInput,
});
expect(responseCache.revalidate).toHaveBeenCalledWith({
id: response.id,
surveyId: response.surveyId,
singleUseId: response.singleUseId,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
}
});
test("update the response and revalidate caches", async () => {
const responseWithoutSingleUseId = { ...response, singleUseId: null };
vi.mocked(prisma.response.update).mockResolvedValue(responseWithoutSingleUseId);
const result = await updateResponse(responseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith({
where: { id: responseId },
data: responseInput,
});
expect(responseCache.revalidate).toHaveBeenCalledWith({
id: response.id,
surveyId: response.surveyId,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(responseWithoutSingleUseId);
}
});
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {

View File

@@ -14,7 +14,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
return (
<FormWrapper>
<>
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
{t("auth.verification-requested.please_confirm_your_email_address")}
</h1>
<p className="text-center text-sm text-slate-700">

View File

@@ -19,7 +19,7 @@ export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrim
return (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none items-center select-none", className)}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
<div

View File

@@ -126,7 +126,7 @@ export const PricingCard = ({
id={plan.id}
className={cn(
plan.featured ? "text-slate-900" : "text-slate-800",
"text-sm leading-6 font-semibold"
"text-sm font-semibold leading-6"
)}>
{t(plan.name)}
</h2>

View File

@@ -41,7 +41,7 @@ export const SingleContactPage = async (props: {
return (
<PageContentWrapper>
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
<section className="pt-6 pb-24">
<section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} />
<ResponseSection

View File

@@ -312,7 +312,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -494,7 +494,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -643,7 +643,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -171,7 +171,7 @@ export function TargetingCard({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
strokeWidth={3}

View File

@@ -228,7 +228,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm text-slate-500 italic">
<p className="text-sm italic text-slate-500">
{t("environments.project.languages.no_language_found")}
</p>
)}

View File

@@ -44,7 +44,7 @@ export function LanguageIndicator({
});
return (
<div className="absolute top-2 right-2">
<div className="absolute right-2 top-2">
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"

View File

@@ -65,7 +65,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
<ChevronDown className="h-4 w-4" />
</Button>
<div
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"}`}>
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"}`}>
<Input
autoComplete="off"
onChange={(e) => {

View File

@@ -186,7 +186,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
@@ -248,7 +248,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
) : (
<>
{projectLanguages.length <= 1 && (
<div className="mb-4 text-sm text-slate-500 italic">
<div className="mb-4 text-sm italic text-slate-500">
{projectLanguages.length === 0
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
: t(
@@ -260,7 +260,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div className="my-4 space-y-4">
<div>
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm text-slate-500 italic">
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
</div>
) : null}

View File

@@ -15,14 +15,14 @@ import { AuthenticationError, OperationNotAllowedError, ValidationError } from "
// Mock constants with getter functions to allow overriding in tests
let mockIsFormbricksCloud = false;
let mockDisableUserManagement = false;
let mockUserManagementMinimumRole = "owner";
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
get DISABLE_USER_MANAGEMENT() {
return mockDisableUserManagement;
get USER_MANAGEMENT_MINIMUM_ROLE() {
return mockUserManagementMinimumRole;
},
}));
@@ -62,7 +62,7 @@ describe("Role Management Actions", () => {
afterEach(() => {
vi.resetAllMocks();
mockIsFormbricksCloud = false;
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "owner";
});
describe("checkRoleManagementPermission", () => {
@@ -220,7 +220,7 @@ describe("Role Management Actions", () => {
test("throws error if user management is disabled", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockDisableUserManagement = true;
mockUserManagementMinimumRole = "disabled";
await expect(
updateMembershipAction({
@@ -231,12 +231,12 @@ describe("Role Management Actions", () => {
data: { role: "member" },
},
} as any)
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
).rejects.toThrow(new OperationNotAllowedError("User management is not allowed for your role"));
});
test("throws error if billing role is not allowed in self-hosted", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "owner";
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
await expect(
@@ -253,7 +253,7 @@ describe("Role Management Actions", () => {
test("allows billing role in cloud environment", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "owner";
mockIsFormbricksCloud = true;
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
@@ -274,7 +274,7 @@ describe("Role Management Actions", () => {
test("throws error if manager tries to assign a role other than member", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "manager";
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
await expect(
@@ -291,7 +291,7 @@ describe("Role Management Actions", () => {
test("allows manager to assign member role", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "manager";
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
@@ -312,7 +312,7 @@ describe("Role Management Actions", () => {
test("successful membership update as owner", async () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
mockDisableUserManagement = false;
mockUserManagementMinimumRole = "owner";
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);

View File

@@ -1,7 +1,8 @@
"use server";
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
@@ -87,8 +88,13 @@ export const updateMembershipAction = authenticatedActionClient
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
if (DISABLE_USER_MANAGEMENT) {
throw new OperationNotAllowedError("User management is disabled");
const hasUserManagementAccess = getUserManagementAccess(
currentUserMembership.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
if (!hasUserManagementAccess) {
throw new OperationNotAllowedError("User management is not allowed for your role");
}
await checkAuthorizationUpdated({

View File

@@ -1,109 +0,0 @@
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,
};

View File

@@ -1,257 +0,0 @@
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,
});
});
});
});

View File

@@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
<div className="mb-10">
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
<div className="mt-2 mb-6 flex items-center gap-4">
<div className="mb-6 mt-2 flex items-center gap-4">
{logoUrl && (
<div className="flex flex-col gap-2">
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
</Button>
</div>
</div>
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pt-10 pb-4">
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
<Image
data-testid="email-customization-preview-image"
src={logoUrl || fbLogoUrl}
@@ -284,7 +284,7 @@ export const EmailCustomizationSettings = ({
)}
{hasWhiteLabelPermission && isReadOnly && (
<Alert variant="warning" className="mt-4 mb-6">
<Alert variant="warning" className="mb-6 mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>

View File

@@ -10,11 +10,11 @@ interface QuestionHeaderProps {
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
return (
<>
<Text className={cn("text-question-color m-0 block text-base leading-6 font-semibold", className)}>
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
{headline}
</Text>
{subheader && (
<Text className="text-question-color m-0 block p-0 text-sm leading-6 font-normal">{subheader}</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
)}
</>
);

View File

@@ -72,8 +72,8 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Consent:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
<Container className="text-question-color m-0 text-sm leading-6 font-normal">
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color m-0 text-sm font-normal leading-6">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
@@ -131,7 +131,7 @@ export async function PreviewEmailTemplate({
)}>
{firstQuestion.isColorCodingEnabled ? (
<Section
className={`absolute top-0 left-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
className={`absolute left-0 top-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
/>
) : null}
{i}
@@ -162,8 +162,8 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.CTA:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
<Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
@@ -227,7 +227,7 @@ export async function PreviewEmailTemplate({
<>
{firstQuestion.isColorCodingEnabled ? (
<Section
className={`absolute top-0 left-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
className={`absolute left-0 top-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
/>
) : null}
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
@@ -315,13 +315,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -369,11 +369,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 px-4 py-2 break-words" />
<Column className="w-40 break-words px-4 py-2" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
key={getLocalizedValue(column, "default")}>
{getLocalizedValue(column, "default")}
</Column>
@@ -385,7 +385,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={getLocalizedValue(row, "default")}>
<Column className="w-40 px-4 py-2 break-words">
<Column className="w-40 break-words px-4 py-2">
{getLocalizedValue(row, "default")}
</Column>
{firstQuestion.columns.map((_) => {

View File

@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 font-bold break-words whitespace-pre-wrap italic">
<Text className="mt-0 whitespace-pre-wrap break-words font-bold italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
@@ -66,6 +66,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 font-bold break-words whitespace-pre-wrap">{response}</Text>;
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
}
};

View File

@@ -66,7 +66,7 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
{variableResponse}
</Text>
</Column>
@@ -84,7 +84,7 @@ export async function ResponseFinishedEmail({
<Text className="mb-2 flex items-center gap-2 font-medium">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
{hiddenFieldResponse}
</Text>
</Column>

View File

@@ -90,7 +90,7 @@ export const WebhookRowData = ({
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
{renderSelectedTriggersText(webhook, t)}
</div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(webhook.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>

View File

@@ -162,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
</div>
<div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center px-6 text-sm font-medium whitespace-nowrap text-slate-400">
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.project.api_keys.no_api_keys_yet")}
</div>
) : (

View File

@@ -10,7 +10,7 @@ const LoadingCard = () => {
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg leading-6 font-medium">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6">
<span className="sr-only">{t("common.loading")}</span>
</h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500">

View File

@@ -71,7 +71,7 @@ export const InviteMemberModal = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"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"
"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"
)}
onClick={() => {
setOpen(false);

View File

@@ -13,7 +13,7 @@ vi.mock(
);
vi.mock("@/lib/constants", () => ({
DISABLE_USER_MANAGEMENT: 0,
USER_MANAGEMENT_MINIMUM_ROLE: "owner",
IS_FORMBRICKS_CLOUD: 1,
ENCRYPTION_KEY: "test-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-key",

View File

@@ -1,5 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -15,6 +16,10 @@ export const TeamsPage = async (props) => {
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
const hasUserManagementAccess = getUserManagementAccess(
currentUserMembership?.role,
USER_MANAGEMENT_MINIMUM_ROLE
);
return (
<PageContentWrapper>
@@ -32,7 +37,7 @@ export const TeamsPage = async (props) => {
currentUserId={session.user.id}
environmentId={params.environmentId}
canDoRoleManagement={canDoRoleManagement}
isUserManagementDisabledFromUi={DISABLE_USER_MANAGEMENT}
isUserManagementDisabledFromUi={!hasUserManagementAccess}
/>
<TeamsView
organizationId={organization.id}

View File

@@ -149,7 +149,7 @@ export const ProjectLookSettingsLoading = () => {
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div>
</div>
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("common.loading")}
</Button>
</div>
@@ -159,7 +159,7 @@ export const ProjectLookSettingsLoading = () => {
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<div className="w-full items-center">
<div className="pointer-events-none flex cursor-not-allowed items-center space-x-2 select-none">
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="signature" checked={false} />
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
</div>

View File

@@ -99,12 +99,12 @@ export const SingleTag: React.FC<SingleTagProps> = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
{!isReadOnly && (
<div className="col-span-1 my-auto flex items-center justify-center gap-2 text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
@@ -139,7 +139,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
<Button
variant="destructive"
size="sm"
className="font-medium text-slate-50 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent"
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")}
</Button>

View File

@@ -179,7 +179,7 @@ export const RecallItemSelect = ({
}
}}
/>
<div className="max-h-72 overflow-x-hidden overflow-y-auto">
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{filteredRecallItems.map((recallItem, index) => {
const IconComponent = getRecallItemIcon(recallItem);
return (
@@ -201,7 +201,7 @@ export const RecallItemSelect = ({
}
}}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
<p className="max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap">
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{getRecallLabel(recallItem.label)}
</p>
</DropdownMenuItem>

View File

@@ -220,7 +220,7 @@ export const RecallWrapper = ({
}
parts.push(
<span
className="z-30 flex h-fit cursor-pointer justify-center rounded-md bg-slate-100 text-sm whitespace-pre text-transparent"
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
key={`recall-${parts.length}`}>
{"@" + label}
</span>
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
<Button
variant="ghost"
type="button"
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"
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"
onClick={(e) => {
e.preventDefault();
setShowFallbackInput(true);

View File

@@ -271,7 +271,7 @@ export const QuestionFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mt-3 mb-2">
<div className="mb-2 mt-3">
<Label htmlFor={id}>{label}</Label>
</div>
)}
@@ -342,7 +342,7 @@ export const QuestionFormInput = ({
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
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 ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"

View File

@@ -45,10 +45,10 @@ export const StartFromScratchTemplate = ({
activeTemplate?.name === customSurvey.name
? "ring-brand-dark border-transparent ring-2"
: "hover:border-brand-dark border-dashed border-slate-300",
"group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-120 duration-150"
"duration-120 group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-150"
)}>
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{customSurvey.name}</h3>
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{customSurvey.name}</h3>
<p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
{activeTemplate?.name === customSurvey.name && (
<div className="text-left">

View File

@@ -41,7 +41,7 @@ export const TemplateFilters = ({
className={cn(
selectedFilter[index] === null
? "bg-slate-800 font-semibold text-white"
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}>
{index === 0
@@ -59,7 +59,7 @@ export const TemplateFilters = ({
className={cn(
selectedFilter[index] === filter.value
? "bg-slate-800 font-semibold text-white"
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}>
{t(filter.label)}

View File

@@ -46,10 +46,10 @@ export const Template = ({
key={template.name}
className={cn(
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
"group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-120 duration-150 hover:ring-2 hover:ring-slate-300"
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:ring-2 hover:ring-slate-300"
)}>
<TemplateTags template={template} selectedFilter={selectedFilter} />
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{template.name}</h3>
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
<p className="text-left text-xs text-slate-600">{template.description}</p>
{activeTemplate?.name === template.name && (
<div className="flex justify-start">

View File

@@ -105,7 +105,7 @@ export const TemplateList = ({
};
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pt-2 pb-6 focus:outline-none">
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-2 focus:outline-none">
{showFilters && !templateSearch && (
<TemplateFilters
selectedFilter={selectedFilter}

View File

@@ -38,7 +38,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
)}>
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
<div className="inline-flex">
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-br group-aria-expanded:rounded-bl-none">
<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">
<PlusIcon className="h-5 w-5 text-white" />
</div>
<div className="px-4 py-3">
@@ -68,7 +68,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
onMouseEnter={() => setHoveredQuestionId(questionType.id)}
onMouseLeave={() => setHoveredQuestionId(null)}>
<div className="flex items-center">
<questionType.icon className="text-brand-dark mr-2 -ml-0.5 h-4 w-4" aria-hidden="true" />
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
{questionType.label}
</div>
<div

View File

@@ -87,21 +87,6 @@ export const CTAQuestionForm = ({
<div className="mt-2 flex justify-between gap-8">
<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 && (
<QuestionFormInput
id="backButtonLabel"
@@ -118,6 +103,20 @@ export const CTAQuestionForm = ({
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>

View File

@@ -167,7 +167,7 @@ export const EditEndingCard = ({
{...attributes}
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 flex-col items-center justify-between rounded-l-lg border-t border-b border-l py-2 group-aria-expanded:rounded-bl-none",
"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",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<div className="mt-3 flex w-full justify-center">
@@ -177,7 +177,7 @@ export const EditEndingCard = ({
<Undo2 className="h-4 w-4 rotate-180" />
)}
</div>
<button className="opacity-0 transition-all duration-300 group-hover:opacity-100 hover:cursor-move">
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
</button>
</div>

View File

@@ -123,7 +123,7 @@ export const EndScreenForm = ({
</Label>
</div>
{showEndingCardCTA && (
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<QuestionFormInput
id="buttonLabel"
@@ -166,7 +166,7 @@ export const EndScreenForm = ({
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
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`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}

View File

@@ -89,7 +89,7 @@ export const FormStylingSettings = ({
)}>
<div className="inline-flex px-4 py-4">
{!isSettingsPage && (
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -113,7 +113,7 @@ export const HiddenFieldsCard = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<EyeOff className="h-4 w-4" />
</div>
@@ -161,7 +161,7 @@ export const HiddenFieldsCard = ({
);
})
) : (
<p className="mt-2 text-sm text-slate-500 italic">
<p className="mt-2 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}

View File

@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
className="h-full w-full cursor-pointer"
id="howToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -1,15 +1,10 @@
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
import { Project } from "@prisma/client";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
// Import waitFor
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyAddressQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
// Import waitFor
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock child components
vi.mock("@/modules/survey/components/question-form-input", () => ({
@@ -371,8 +366,9 @@ describe("QuestionCard Component", () => {
test("applies invalid styling when isInvalid is true", () => {
render(<QuestionCard {...defaultProps} isInvalid={true} />);
const dragHandle = screen.getByRole("button", { name: "" }).parentElement; // Get the div containing the GripIcon
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" }).parentElement; // Get the div containing the GripIcon
expect(dragHandle).toHaveClass("bg-red-400");
expect(dragHandle).toHaveClass("hover:bg-red-600");
});
test("disables required toggle for Address question if all fields are optional", () => {
@@ -507,4 +503,94 @@ describe("QuestionCard Component", () => {
// First question should never have back button
expect(screen.queryByTestId("question-form-input-backButtonLabel")).not.toBeInTheDocument();
});
// Accessibility Tests
test("maintains proper focus management when toggling advanced settings", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
await user.click(advancedSettingsTrigger);
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
expect(closeTrigger).toBeInTheDocument();
});
test("ensures proper ARIA attributes for collapsible sections", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(collapsibleTrigger);
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "true");
});
test("maintains keyboard accessibility for required toggle", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
await user.click(requiredToggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
});
test("provides screen reader text for drag handle", () => {
render(<QuestionCard {...defaultProps} />);
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" });
const svg = dragHandle.querySelector("svg");
expect(svg).toHaveAttribute("aria-hidden", "true");
});
test("maintains proper heading hierarchy", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const headline = screen.getByText("Question Headline");
expect(headline.tagName).toBe("H3");
expect(headline).toHaveClass("text-sm", "font-semibold");
});
test("ensures proper focus order for form elements", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
// Open advanced settings
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
await user.click(requiredToggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
});
test("provides proper ARIA attributes for interactive elements", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
expect(requiredToggle).toHaveAttribute("aria-checked", "true");
const longAnswerToggle = screen.getByRole("switch", { name: "environments.surveys.edit.long_answer" });
expect(longAnswerToggle).toHaveAttribute("aria-checked", "false");
});
test("ensures proper role attributes for interactive elements", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const toggles = screen.getAllByRole("switch");
expect(toggles).toHaveLength(2); // Required and Long Answer toggles
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
expect(collapsibleTrigger).toHaveAttribute("type", "button");
});
test("maintains proper focus management when closing advanced settings", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
await user.click(advancedSettingsTrigger);
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
await user.click(closeTrigger);
expect(screen.getByText("environments.surveys.edit.show_advanced_settings")).toBeInTheDocument();
});
});

View File

@@ -196,7 +196,9 @@ export const QuestionCard = ({
)}>
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
aria-label="Drag to reorder question">
<GripIcon className="h-4 w-4" />
</button>
</div>
@@ -215,14 +217,15 @@ export const QuestionCard = ({
className={cn(
open ? "" : " ",
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
)}>
)}
aria-label="Toggle question details">
<div>
<div className="flex grow">
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{QUESTIONS_ICON_MAP[question.type]}
</div> */}
<div className="flex grow flex-col justify-center" dir="auto">
<p className="text-sm font-semibold">
<h3 className="text-sm font-semibold">
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
@@ -232,7 +235,7 @@ export const QuestionCard = ({
] ?? ""
)
: getTSurveyQuestionTypeEnumName(question.type, t)}
</p>
</h3>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{question?.required
@@ -272,7 +275,7 @@ export const QuestionCard = ({
TSurveyQuestionTypeEnum.Ranking,
TSurveyQuestionTypeEnum.Matrix,
].includes(question.type) ? (
<Alert variant="warning" size="small" className="w-fill">
<Alert variant="warning" size="small" className="w-fill" role="alert">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
</Alert>
@@ -457,7 +460,9 @@ export const QuestionCard = ({
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
<Collapsible.CollapsibleTrigger className="flex items-center text-sm text-slate-700">
<Collapsible.CollapsibleTrigger
className="flex items-center text-sm text-slate-700"
aria-label="Toggle advanced settings">
{openAdvanced ? (
<ChevronDownIcon className="mr-1 h-4 w-3" />
) : (
@@ -473,6 +478,30 @@ export const QuestionCard = ({
question.type !== TSurveyQuestionTypeEnum.Rating &&
question.type !== TSurveyQuestionTypeEnum.CTA ? (
<div className="mt-2 flex space-x-2">
{questionIdx !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={t("common.back")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!question.backButtonLabel) return;
let translatedBackButtonLabel = {
...question.backButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
}}
/>
)}
<div className="w-full">
<QuestionFormInput
id="buttonLabel"
@@ -503,30 +532,6 @@ export const QuestionCard = ({
locale={locale}
/>
</div>
{questionIdx !== 0 && (
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
label={t("environments.surveys.edit.back_button_label")}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={t("common.back")}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
onBlur={(e) => {
if (!question.backButtonLabel) return;
let translatedBackButtonLabel = {
...question.backButtonLabel,
[selectedLanguageCode]: e.target.value,
};
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
}}
/>
)}
</div>
) : null}
{(question.type === TSurveyQuestionTypeEnum.Rating ||

View File

@@ -121,7 +121,7 @@ export const RecontactOptionsCard = ({
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
id="recontactOptionsCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -256,7 +256,7 @@ export const RecontactOptionsCard = ({
id="inputDays"
value={inputDays === 0 ? 1 : inputDays}
onChange={handleRecontactDaysChange}
className="mr-2 ml-2 inline w-16 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.days_before_showing_this_survey_again")}.
</p>

View File

@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
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`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}

View File

@@ -64,7 +64,7 @@ export const SavedActionsTab = ({
(actions, i) =>
actions.length > 0 && (
<div key={i} className="me-4">
<h2 className="mt-4 mb-2 font-semibold">
<h2 className="mb-2 mt-4 font-semibold">
{i === 0 ? t("common.no_code") : t("common.code")}
</h2>
<div className="flex flex-col gap-2">

View File

@@ -329,7 +329,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
{responseCount > 0 && (
<div>
<Alert variant="warning" size="small">

View File

@@ -24,7 +24,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-6">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<div className="rounded-full border border-slate-300 bg-slate-100 p-1">
<LockIcon className="h-4 w-4 text-slate-500" strokeWidth={3} />
</div>

View File

@@ -192,7 +192,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
return (
<div className="relative mt-2 w-full">
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2 h-6 w-4 -translate-y-1/2 text-slate-500" />
<SearchIcon className="absolute left-2 top-1/2 h-6 w-4 -translate-y-1/2 text-slate-500" />
<Input
value={query}
onChange={handleChange}
@@ -215,7 +215,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
className="h-full cursor-pointer rounded-lg object-cover"
/>
{image.authorName && (
<span className="bg-opacity-75 absolute right-1 bottom-1 hidden rounded bg-black px-2 py-1 text-xs text-white group-hover:block">
<span className="absolute bottom-1 right-1 hidden rounded bg-black bg-opacity-75 px-2 py-1 text-xs text-white group-hover:block">
{image.authorName}
</span>
)}

View File

@@ -155,7 +155,7 @@ export const WhenToSendCard = ({
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
id="whenToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
{containsEmptyTriggers ? (
<div className="h-7 w-7 rounded-full border border-amber-500 bg-amber-50" />
) : (
@@ -178,7 +178,7 @@ export const WhenToSendCard = ({
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<div className="px-3 pt-1 pb-3">
<div className="px-3 pb-3 pt-1">
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
<p className="text-sm font-semibold text-slate-800">
{t("environments.surveys.edit.trigger_survey_when_one_of_the_actions_is_fired")}
@@ -265,7 +265,7 @@ export const WhenToSendCard = ({
</div>
{/* Survey Display Settings */}
<div className="mt-8 mb-4 space-y-1 px-4">
<div className="mb-4 mt-8 space-y-1 px-4">
<h3 className="font-semibold text-slate-800">
{t("environments.surveys.edit.survey_display_settings")}
</h3>
@@ -294,7 +294,7 @@ export const WhenToSendCard = ({
id="triggerDelay"
value={localSurvey.delay.toString()}
onChange={(e) => handleTriggerDelay(e)}
className="mr-2 ml-2 inline w-16 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.seconds_before_showing_the_survey")}
</p>

View File

@@ -146,7 +146,7 @@ export const FollowUpItem = ({
</div>
</div>
<div className="absolute top-4 right-4 flex items-center">
<div className="absolute right-4 top-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"

View File

@@ -856,7 +856,7 @@ export const FollowUpModal = ({
</div>
</div>
<div className="absolute right-0 bottom-0 z-20 h-12 w-full bg-white p-2">
<div className="absolute bottom-0 right-0 z-20 h-12 w-full bg-white p-2">
<div className="flex justify-end space-x-2">
<Button
type="button"

View File

@@ -82,7 +82,7 @@ export const LinkSurveyWrapper = ({
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />

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