Compare commits

..

10 Commits

Author SHA1 Message Date
Matthias Nannt
94f68480bf solve merge conflicts 2025-05-06 20:52:58 +02:00
Vijay
53850c96db fix: sonar security hotspots (https, --ignore-scripts, api_key, math.random) (#5538)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 20:41:35 +02:00
Matthias Nannt
d0adbcb39b chore: remove unused npm dependencies from docker image 2025-05-06 20:31:02 +02:00
Vijay
ae2cb15055 fix: sonar security hotspot (permission issue - non-root user in Dockerfile) (#5411)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 19:14:51 +02:00
Matti Nannt
8bf1e096c0 chore: move dependencies to devDependencies if possible (#5679) 2025-05-06 18:57:51 +02:00
Anshuman Pandey
0052dc88f0 fix: increases language button size (#5677) 2025-05-06 16:07:26 +00:00
Matti Nannt
d67d62df45 chore: update zod dependency, remove unused labeler action (#5678) 2025-05-06 18:18:27 +02:00
Piyush Gupta
5d45de6bc4 feat: adds unit tests in modules/ee/teams (#5620) 2025-05-06 12:31:43 +00:00
Piyush Gupta
cf5bc51e94 fix: strict recaptcha checks (#5674) 2025-05-06 12:13:28 +00:00
Dhruwang Jariwala
9a7d24ea4e chore: updated open telemtry package versions (#5672) 2025-05-06 11:59:54 +00:00
83 changed files with 2578 additions and 992 deletions

View File

@@ -1,27 +0,0 @@
name: "Pull Request Labeler"
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
name: Pull Request Labeler
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
sync-labels: ""

View File

@@ -18,8 +18,9 @@ FROM node:22-alpine3.21 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@9.15.0 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -59,7 +60,7 @@ COPY . .
RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
RUN pnpm install --ignore-scripts
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
@@ -75,8 +76,9 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@9.15.0 --activate
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -141,12 +143,12 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
RUN npm install --ignore-scripts -g tsx typescript prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/

View File

@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>

View File

@@ -264,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -118,7 +118,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}

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 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

@@ -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

@@ -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

@@ -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

@@ -164,7 +164,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
)}
<div>

View File

@@ -6,11 +6,11 @@ import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
Resource,
detectResourcesSync,
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
@@ -21,11 +21,11 @@ const exporter = new PrometheusExporter({
host: "0.0.0.0", // Listen on all network interfaces
});
const detectedResources = detectResourcesSync({
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
const customResources = new Resource({});
const customResources = resourceFromAttributes({});
const resources = detectedResources.merge(customResources);

View File

@@ -82,7 +82,7 @@ export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID;
export const SMTP_HOST = env.SMTP_HOST;
export const SMTP_PORT = env.SMTP_PORT;
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1";
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1" || env.SMTP_PORT === "465";
export const SMTP_USER = env.SMTP_USER;
export const SMTP_PASSWORD = env.SMTP_PASSWORD;
export const SMTP_AUTHENTICATED = env.SMTP_AUTHENTICATED !== "0";

View File

@@ -202,7 +202,7 @@ const baseSurveyProperties = {
autoComplete: 7,
runOnDate: null,
closeOnDate: currentDate,
redirectUrl: "http://github.com/formbricks/formbricks",
redirectUrl: "https://github.com/formbricks/formbricks",
recontactDays: 3,
displayLimit: 3,
welcomeCard: mockWelcomeCard,

View File

@@ -22,7 +22,7 @@ export const captureTelemetry = async (eventName: string, properties = {}) => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy",
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
event: eventName,
properties: {
distinct_id: getTelemetryId(),

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

@@ -0,0 +1,113 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
describe("roles lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getProjectPermissionByUserId", () => {
test("returns null if no memberships", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([]);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockProjectId, expect.anything()]
);
});
test("returns 'manage' if any membership has manage", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "manage" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("manage");
});
test("returns 'readWrite' if highest is readWrite", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("readWrite");
});
test("returns 'read' if only read", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([{ permission: "read" }] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("read");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(error, expect.any(String));
});
test("throws UnknownError on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(UnknownError);
});
});
describe("getTeamRoleByTeamIdUserId", () => {
test("returns null if no teamUser", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce(null);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockTeamId, expect.anything()],
[mockUserId, expect.anything()]
);
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
});

View File

@@ -0,0 +1,41 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessTable } from "./access-table";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k: string) => k }),
}));
describe("AccessTable", () => {
afterEach(() => {
cleanup();
});
test("renders no teams found row when teams is empty", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.no_teams_found")).toBeInTheDocument();
});
test("renders team rows with correct data and permission mapping", () => {
const teams: TProjectTeam[] = [
{ id: "1", name: "Team A", memberCount: 1, permission: "readWrite" },
{ id: "2", name: "Team B", memberCount: 2, permission: "read" },
];
render(<AccessTable teams={teams} />);
expect(screen.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Team B")).toBeInTheDocument();
expect(screen.getByText("1 common.member")).toBeInTheDocument();
expect(screen.getByText("2 common.members")).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["readWrite"])).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["read"])).toBeInTheDocument();
});
test("renders table headers with tolgee keys", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.size")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.permission")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessView } from "./access-view";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="SettingsCard">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/manage-team", () => ({
ManageTeam: ({ environmentId, isOwnerOrManager }: any) => (
<button data-testid="ManageTeam">
ManageTeam {environmentId} {isOwnerOrManager ? "owner" : "not-owner"}
</button>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/access-table", () => ({
AccessTable: ({ teams }: any) => (
<div data-testid="AccessTable">
{teams.length === 0 ? "No teams" : `Teams: ${teams.map((t: any) => t.name).join(",")}`}
</div>
),
}));
describe("AccessView", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
environmentId: "env-1",
isOwnerOrManager: true,
teams: [
{ id: "1", name: "Team A", memberCount: 2, permission: "readWrite" } as TProjectTeam,
{ id: "2", name: "Team B", memberCount: 1, permission: "read" } as TProjectTeam,
],
};
test("renders SettingsCard with tolgee strings and children", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("SettingsCard")).toBeInTheDocument();
expect(screen.getByText("common.team_access")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.team_settings_description")).toBeInTheDocument();
});
test("renders ManageTeam with correct props", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("ManageTeam env-1 owner");
});
test("renders AccessTable with teams", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("Teams: Team A,Team B");
});
test("renders AccessTable with no teams", () => {
render(<AccessView {...baseProps} teams={[]} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("No teams");
});
test("renders ManageTeam as not-owner when isOwnerOrManager is false", () => {
render(<AccessView {...baseProps} isOwnerOrManager={false} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("not-owner");
});
});

View File

@@ -0,0 +1,46 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ManageTeam } from "./manage-team";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ tooltipContent, children }: any) => (
<div data-testid="TooltipRenderer">
<span>{tooltipContent}</span>
{children}
</div>
),
}));
describe("ManageTeam", () => {
afterEach(() => {
cleanup();
});
test("renders enabled button and navigates when isOwnerOrManager is true", async () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={true} />);
const button = screen.getByRole("button");
expect(button).toBeEnabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
await userEvent.click(button);
});
test("renders disabled button with tooltip when isOwnerOrManager is false", () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={false} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(
screen.getByText("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getTeamsByProjectId } from "./team";
vi.mock("@formbricks/database", () => ({
prisma: {
project: { findUnique: vi.fn() },
team: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache/team", () => ({ teamCache: { tag: { byProjectId: vi.fn(), byId: vi.fn() } } }));
vi.mock("@/lib/project/cache", () => ({ projectCache: { tag: { byId: vi.fn() } } }));
const mockProject = { id: "p1" };
const mockTeams = [
{
id: "t1",
name: "Team 1",
projectTeams: [{ permission: "readWrite" }],
_count: { teamUsers: 2 },
},
{
id: "t2",
name: "Team 2",
projectTeams: [{ permission: "manage" }],
_count: { teamUsers: 3 },
},
];
describe("getTeamsByProjectId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams for valid project", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByProjectId("p1");
expect(result).toEqual([
{ id: "t1", name: "Team 1", permission: "readWrite", memberCount: 2 },
{ id: "t2", name: "Team 2", permission: "manage", memberCount: 3 },
]);
expect(prisma.project.findUnique).toHaveBeenCalledWith({ where: { id: "p1" } });
expect(prisma.team.findMany).toHaveBeenCalled();
});
test("throws ResourceNotFoundError if project does not exist", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(null);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on Prisma known error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(DatabaseError);
});
test("throws unknown error on unexpected error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("unexpected"));
await expect(getTeamsByProjectId("p1")).rejects.toThrow("unexpected");
});
});

View File

@@ -0,0 +1,41 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsLoading } from "./loading";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId, loading }: any) => (
<div data-testid="ProjectConfigNavigation">{`${activeId}-${loading}`}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="PageContentWrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="PageHeader">
<span>{pageTitle}</span>
{children}
</div>
),
}));
describe("TeamsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons and navigation", () => {
render(<TeamsLoading />);
expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument();
expect(screen.getByTestId("PageHeader")).toBeInTheDocument();
expect(screen.getByTestId("ProjectConfigNavigation")).toHaveTextContent("teams-true");
// Check for the presence of multiple skeleton loaders (at least one)
const skeletonLoaders = screen.getAllByRole("generic", { name: "" }); // Assuming skeleton divs don't have specific roles/names
// Filter for elements with animate-pulse class
const pulseElements = skeletonLoaders.filter((el) => el.classList.contains("animate-pulse"));
expect(pulseElements.length).toBeGreaterThan(0);
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getTeamsByProjectId } from "./lib/team";
import { ProjectTeams } from "./page";
vi.mock("@/modules/ee/teams/project-teams/components/access-view", () => ({
AccessView: (props: any) => <div data-testid="AccessView">{JSON.stringify(props)}</div>,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => (
<div data-testid="ProjectConfigNavigation">{JSON.stringify(props)}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="PageContentWrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="PageHeader">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("./lib/team", () => ({
getTeamsByProjectId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
describe("ProjectTeams", () => {
const params = Promise.resolve({ environmentId: "env-1" });
beforeEach(() => {
vi.mocked(getTeamsByProjectId).mockResolvedValue([
{ id: "team-1", name: "Team 1", memberCount: 2, permission: "readWrite" },
{ id: "team-2", name: "Team 2", memberCount: 1, permission: "read" },
]);
vi.mocked(getTranslate).mockResolvedValue((key) => key);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
project: { id: "project-1" },
isOwner: true,
isManager: false,
} as any);
});
afterEach(() => {
cleanup();
});
test("renders all main components and passes correct props", async () => {
const ui = await ProjectTeams({ params });
render(ui);
expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument();
expect(screen.getByTestId("PageHeader")).toBeInTheDocument();
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByTestId("ProjectConfigNavigation")).toBeInTheDocument();
expect(screen.getByTestId("AccessView")).toHaveTextContent('"environmentId":"env-1"');
expect(screen.getByTestId("AccessView")).toHaveTextContent('"isOwnerOrManager":true');
});
test("throws error if teams is null", async () => {
vi.mocked(getTeamsByProjectId).mockResolvedValue(null);
await expect(ProjectTeams({ params })).rejects.toThrow("common.teams_not_found");
});
});

View File

@@ -0,0 +1,86 @@
import { ZTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createTeamAction,
deleteTeamAction,
getTeamDetailsAction,
getTeamRoleAction,
updateTeamDetailsAction,
} from "./actions";
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => fn,
}),
},
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromTeamId: vi.fn(async (id: string) => `org-${id}`),
}));
vi.mock("@/modules/ee/role-management/actions", () => ({
checkRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getTeamRoleByTeamIdUserId: vi.fn(async () => "admin"),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
createTeam: vi.fn(async () => "team-created"),
getTeamDetails: vi.fn(async () => ({ id: "team-1" })),
deleteTeam: vi.fn(async () => true),
updateTeamDetails: vi.fn(async () => ({ updated: true })),
}));
describe("action.ts", () => {
const ctx = {
user: { id: "user-1" },
} as any;
afterEach(() => {
cleanup();
});
test("createTeamAction calls dependencies and returns result", async () => {
const result = await createTeamAction({
ctx,
parsedInput: { organizationId: "org-1", name: "Team X" },
} as any);
expect(result).toBe("team-created");
});
test("getTeamDetailsAction calls dependencies and returns result", async () => {
const result = await getTeamDetailsAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toEqual({ id: "team-1" });
});
test("deleteTeamAction calls dependencies and returns result", async () => {
const result = await deleteTeamAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toBe(true);
});
test("updateTeamDetailsAction calls dependencies and returns result", async () => {
const result = await updateTeamDetailsAction({
ctx,
parsedInput: { teamId: "team-1", data: {} as typeof ZTeamSettingsFormSchema._type },
} as any);
expect(result).toEqual({ updated: true });
});
test("getTeamRoleAction calls dependencies and returns result", async () => {
const result = await getTeamRoleAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toBe("admin");
});
});

View File

@@ -0,0 +1,27 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamButton } from "./create-team-button";
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
CreateTeamModal: ({ open, setOpen, organizationId }: any) =>
open ? <div data-testid="CreateTeamModal">{organizationId}</div> : null,
}));
describe("CreateTeamButton", () => {
afterEach(() => {
cleanup();
});
test("renders button with tolgee string", () => {
render(<CreateTeamButton organizationId="org-1" />);
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
});
test("opens CreateTeamModal on button click", async () => {
render(<CreateTeamButton organizationId="org-2" />);
await userEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("CreateTeamModal")).toHaveTextContent("org-2");
});
});

View File

@@ -0,0 +1,77 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamModal } from "./create-team-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children }: any) => <div data-testid="Modal">{children}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
createTeamAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "error-message"),
}));
describe("CreateTeamModal", () => {
afterEach(() => {
cleanup();
});
const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create")).toBeInTheDocument();
});
test("calls setOpen(false) and resets teamName on cancel", async () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("common.cancel"));
expect(setOpen).toHaveBeenCalledWith(false);
expect((input as HTMLInputElement).value).toBe("");
});
test("submit button is disabled when input is empty", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
});
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => {
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
const onCreate = vi.fn();
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("environments.settings.teams.create"));
await waitFor(() => {
expect(createTeamAction).toHaveBeenCalledWith({ name: "My Team", organizationId: "org-1" });
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_created_successfully");
expect(onCreate).toHaveBeenCalledWith("team-123");
expect(setOpen).toHaveBeenCalledWith(false);
expect((input as HTMLInputElement).value).toBe("");
});
});
test("shows error toast if createTeamAction fails", async () => {
vi.mocked(createTeamAction).mockResolvedValue({});
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("environments.settings.teams.create"));
await waitFor(() => {
expect(getFormattedErrorMessage).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("error-message");
});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/action";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";

View File

@@ -0,0 +1,42 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ManageTeamButton } from "./manage-team-button";
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) =>
shouldRender ? (
<div data-testid="TooltipRenderer">
{tooltipContent}
{children}
</div>
) : (
<>{children}</>
),
}));
describe("ManageTeamButton", () => {
afterEach(() => {
cleanup();
});
test("renders enabled button and calls onClick", async () => {
const onClick = vi.fn();
render(<ManageTeamButton onClick={onClick} disabled={false} />);
const button = screen.getByRole("button");
expect(button).toBeEnabled();
expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument();
await userEvent.click(button);
expect(onClick).toHaveBeenCalled();
});
test("renders disabled button with tooltip", () => {
const onClick = vi.fn();
render(<ManageTeamButton onClick={onClick} disabled={true} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument();
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.manage_team_disabled")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,99 @@
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DeleteTeam } from "./delete-team";
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: any) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) =>
shouldRender ? (
<div data-testid="TooltipRenderer">
{tooltipContent}
{children}
</div>
) : (
<>{children}</>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, deleteWhat, text, onDelete, isDeleting }: any) =>
open ? (
<div data-testid="DeleteDialog">
<span>{deleteWhat}</span>
<span>{text}</span>
<button onClick={onDelete} disabled={isDeleting}>
Confirm
</button>
</div>
) : null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
deleteTeamAction: vi.fn(),
}));
describe("DeleteTeam", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
teamId: "team-1" as TTeam["id"],
onDelete: vi.fn(),
isOwnerOrManager: true,
};
test("renders danger zone label and delete button enabled for owner/manager", () => {
render(<DeleteTeam {...baseProps} />);
expect(screen.getByText("common.danger_zone")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeEnabled();
});
test("renders tooltip and disables button if not owner/manager", () => {
render(<DeleteTeam {...baseProps} isOwnerOrManager={false} />);
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_deletion_not_allowed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeDisabled();
});
test("opens dialog on delete button click", async () => {
render(<DeleteTeam {...baseProps} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
expect(screen.getByTestId("DeleteDialog")).toBeInTheDocument();
expect(screen.getByText("common.team")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.teams.are_you_sure_you_want_to_delete_this_team")
).toBeInTheDocument();
});
test("calls deleteTeamAction, shows success toast, calls onDelete, and refreshes on confirm", async () => {
vi.mocked(deleteTeamAction).mockResolvedValue({ data: true });
const onDelete = vi.fn();
render(<DeleteTeam {...baseProps} onDelete={onDelete} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
await userEvent.click(screen.getByText("Confirm"));
expect(deleteTeamAction).toHaveBeenCalledWith({ teamId: baseProps.teamId });
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_deleted_successfully");
expect(onDelete).toHaveBeenCalled();
});
test("shows error toast if deleteTeamAction fails", async () => {
vi.mocked(deleteTeamAction).mockResolvedValue({ data: false });
render(<DeleteTeam {...baseProps} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
await userEvent.click(screen.getByText("Confirm"));
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/action";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";

View File

@@ -0,0 +1,136 @@
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { TOrganizationMember, TTeamDetails, ZTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamSettingsModal } from "./team-settings-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, ...props }: any) => <div data-testid="Modal">{children}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
DeleteTeam: () => <div data-testid="DeleteTeam" />,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
updateTeamDetailsAction: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
describe("TeamSettingsModal", () => {
afterEach(() => {
cleanup();
});
const orgMembers: TOrganizationMember[] = [
{ id: "1", name: "Alice", role: "member" },
{ id: "2", name: "Bob", role: "manager" },
];
const orgProjects = [
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
];
const team: TTeamDetails = {
id: "t1",
name: "Team 1",
members: [{ name: "Alice", userId: "1", role: ZTeamRole.enum.contributor }],
projects: [
{ projectName: "pro1", projectId: "p1", permission: ZTeamPermission.enum.read },
{ projectName: "pro2", projectId: "p2", permission: ZTeamPermission.enum.readWrite },
],
organizationId: "org1",
};
const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => {
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument();
expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Add project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByTestId("DeleteTeam")).toBeInTheDocument();
});
test("calls setOpen(false) when cancel button is clicked", async () => {
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.cancel"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("calls updateTeamDetailsAction and shows success toast on submit", async () => {
vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: true });
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(updateTeamDetailsAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_updated_successfully");
expect(setOpen).toHaveBeenCalledWith(false);
});
});
test("shows error toast if updateTeamDetailsAction fails", async () => {
vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: false });
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,7 @@ import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/action";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {
@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({
<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={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />

View File

@@ -0,0 +1,154 @@
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions";
import { TOrganizationMember, TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsTable } from "./teams-table";
vi.mock("@/modules/ee/teams/team-list/components/create-team-button", () => ({
CreateTeamButton: ({ organizationId }: any) => (
<button data-testid="CreateTeamButton">{organizationId}</button>
),
}));
vi.mock("@/modules/ee/teams/team-list/components/manage-team-button", () => ({
ManageTeamButton: ({ disabled, onClick }: any) => (
<button data-testid="ManageTeamButton" disabled={disabled} onClick={onClick}>
environments.settings.teams.manage_team
</button>
),
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/team-settings-modal", () => ({
TeamSettingsModal: (props: any) => <div data-testid="TeamSettingsModal">{props.team?.name}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
getTeamDetailsAction: vi.fn(),
getTeamRoleAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: ({ text }: any) => <span data-testid="Badge">{text}</span>,
}));
const userTeams: TUserTeam[] = [
{ id: "1", name: "Alpha", memberCount: 2, userRole: "admin" },
{ id: "2", name: "Beta", memberCount: 1, userRole: "contributor" },
];
const otherTeams: TOtherTeam[] = [
{ id: "3", name: "Gamma", memberCount: 3 },
{ id: "4", name: "Delta", memberCount: 1 },
];
const orgMembers: TOrganizationMember[] = [{ id: "u1", name: "User 1", role: "manager" }];
const orgProjects = [{ id: "p1", name: "Project 1" }];
describe("TeamsTable", () => {
afterEach(() => {
cleanup();
});
test("renders CreateTeamButton for owner/manager", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByTestId("CreateTeamButton")).toHaveTextContent("org-1");
});
test("does not render CreateTeamButton for non-owner/manager", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole={undefined}
currentUserId="u1"
/>
);
expect(screen.queryByTestId("CreateTeamButton")).toBeNull();
});
test("renders empty state row if no teams", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByText("environments.settings.teams.empty_teams_state")).toBeInTheDocument();
});
test("renders userTeams and otherTeams rows", () => {
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
expect(screen.getByText("Delta")).toBeInTheDocument();
expect(screen.getAllByTestId("ManageTeamButton").length).toBe(4);
expect(screen.getAllByTestId("Badge")[0]).toHaveTextContent(
"environments.settings.teams.you_are_a_member"
);
expect(screen.getByText("2 common.members")).toBeInTheDocument();
});
test("opens TeamSettingsModal when ManageTeamButton is clicked and team details are returned", async () => {
vi.mocked(getTeamDetailsAction).mockResolvedValue({
data: { id: "1", name: "Alpha", organizationId: "org-1", members: [], projects: [] },
});
vi.mocked(getTeamRoleAction).mockResolvedValue({ data: "admin" });
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]);
await waitFor(() => {
expect(screen.getByTestId("TeamSettingsModal")).toHaveTextContent("Alpha");
});
});
test("shows error toast if getTeamDetailsAction fails", async () => {
vi.mocked(getTeamDetailsAction).mockResolvedValue({ data: undefined });
vi.mocked(getTeamRoleAction).mockResolvedValue({ data: undefined });
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]);
await waitFor(() => {
expect(toast.error).toHaveBeenCalled();
});
});
});

View File

@@ -2,7 +2,7 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/action";
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions";
import { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button";
import { ManageTeamButton } from "@/modules/ee/teams/team-list/components/manage-team-button";
import { TeamSettingsModal } from "@/modules/ee/teams/team-list/components/team-settings/team-settings-modal";

View File

@@ -0,0 +1,50 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { getProjectsByOrganizationId } from "./project";
vi.mock("@formbricks/database", () => ({
prisma: {
project: { findMany: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
const mockProjects = [
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
];
describe("getProjectsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped projects for valid organization", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const result = await getProjectsByOrganizationId("org1");
expect(result).toEqual([
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { organizationId: "org1" },
select: { id: true, name: true },
});
});
test("throws DatabaseError on Prisma known error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error);
await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(error, "Error fetching projects by organization id");
});
test("throws UnknownError on unknown error", async () => {
const error = new Error("fail");
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error);
await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(UnknownError);
});
});

View File

@@ -0,0 +1,343 @@
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import { projectCache } from "@/lib/project/cache";
import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
createTeam,
deleteTeam,
getOtherTeams,
getTeamDetails,
getTeams,
getTeamsByOrganizationId,
getUserTeams,
updateTeamDetails,
} from "./team";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
membership: { findUnique: vi.fn(), count: vi.fn() },
project: { count: vi.fn() },
environment: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn(), byUserId: vi.fn(), byId: vi.fn(), projectId: vi.fn() },
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: { tag: { byId: vi.fn(), byOrganizationId: vi.fn() }, revalidate: vi.fn() },
}));
vi.mock("@/lib/cache/organization", () => ({ organizationCache: { revalidate: vi.fn() } }));
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
const mockUserTeams = [
{
id: "t1",
name: "Team 1",
teamUsers: [{ role: "admin" }],
_count: { teamUsers: 2 },
},
];
const mockOtherTeams = [
{
id: "t2",
name: "Team 2",
_count: { teamUsers: 3 },
},
];
const mockMembership = { role: "admin" };
const mockTeamDetails = {
id: "t1",
name: "Team 1",
organizationId: "org1",
teamUsers: [
{ userId: "u1", role: "admin", user: { name: "User 1" } },
{ userId: "u2", role: "member", user: { name: "User 2" } },
],
projectTeams: [{ projectId: "p1", project: { name: "Project 1" }, permission: "manage" }],
};
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
expect(result).toEqual([
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
});
describe("getUserTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped user teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
const result = await getUserTeams("u1", "org1");
expect(result).toEqual([{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getUserTeams("u1", "org1")).rejects.toThrow(DatabaseError);
});
});
describe("getOtherTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped other teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams);
const result = await getOtherTeams("u1", "org1");
expect(result).toEqual([{ id: "t2", name: "Team 2", memberCount: 3 }]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getOtherTeams("u1", "org1")).rejects.toThrow(DatabaseError);
});
});
describe("getTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns userTeams and otherTeams", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(mockMembership);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams);
const result = await getTeams("u1", "org1");
expect(result).toEqual({
userTeams: [{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }],
otherTeams: [{ id: "t2", name: "Team 2", memberCount: 3 }],
});
});
test("throws ResourceNotFoundError if membership not found", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(null);
await expect(getTeams("u1", "org1")).rejects.toThrow(ResourceNotFoundError);
});
});
describe("createTeam", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("creates and returns team id", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.team.create).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await createTeam("org1", "Team 1");
expect(result).toBe("t1");
expect(teamCache.revalidate).toHaveBeenCalledWith({ organizationId: "org1" });
});
test("throws InvalidInputError if team exists", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({ id: "t1" });
await expect(createTeam("org1", "Team 1")).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError if name too short", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null);
await expect(createTeam("org1", "")).rejects.toThrow(InvalidInputError);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findFirst).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(createTeam("org1", "Team 1")).rejects.toThrow(DatabaseError);
});
});
describe("getTeamDetails", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped team details", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails);
const result = await getTeamDetails("t1");
expect(result).toEqual({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [
{ userId: "u1", name: "User 1", role: "admin" },
{ userId: "u2", name: "User 2", role: "member" },
],
projects: [{ projectId: "p1", projectName: "Project 1", permission: "manage" }],
});
});
test("returns null if team not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
const result = await getTeamDetails("t1");
expect(result).toBeNull();
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamDetails("t1")).rejects.toThrow(DatabaseError);
});
});
describe("deleteTeam", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes team and revalidates caches", async () => {
const mockTeam = {
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
projectTeams: [{ projectId: "p1" }],
};
vi.mocked(prisma.team.delete).mockResolvedValueOnce(mockTeam);
const result = await deleteTeam("t1");
expect(result).toBe(true);
expect(teamCache.revalidate).toHaveBeenCalledWith({ id: "t1", organizationId: "org1" });
expect(teamCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.delete).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(deleteTeam("t1")).rejects.toThrow(DatabaseError);
});
});
describe("updateTeamDetails", () => {
const data: TTeamSettingsFormSchema = {
name: "Team 1 Updated",
members: [{ userId: "u1", role: "admin" }],
projects: [{ projectId: "p1", permission: "manage" }],
};
beforeEach(() => {
vi.clearAllMocks();
});
test("updates team details and revalidates caches", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
vi.mocked(prisma.membership.count).mockResolvedValueOnce(1);
vi.mocked(prisma.project.count).mockResolvedValueOnce(1);
vi.mocked(prisma.team.update).mockResolvedValueOnce({
id: "t1",
name: "Team 1 Updated",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.environment.findMany).mockResolvedValueOnce([{ id: "env1" }]);
const result = await updateTeamDetails("t1", data);
expect(result).toBe(true);
expect(teamCache.revalidate).toHaveBeenCalled();
expect(projectCache.revalidate).toHaveBeenCalled();
expect(organizationCache.revalidate).toHaveBeenCalledWith({ environmentId: "env1" });
});
test("throws ResourceNotFoundError if team not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
await expect(updateTeamDetails("t1", data)).rejects.toThrow(ResourceNotFoundError);
});
test("throws error if getTeamDetails returns null", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
await expect(updateTeamDetails("t1", data)).rejects.toThrow("Team not found");
});
test("throws error if user not in org membership", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
vi.mocked(prisma.membership.count).mockResolvedValueOnce(0);
await expect(updateTeamDetails("t1", data)).rejects.toThrow();
});
test("throws error if project not in org", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
vi.mocked(prisma.membership.count).mockResolvedValueOnce(1);
vi.mocked(prisma.project.count).mockResolvedValueOnce(0);
await expect(
updateTeamDetails("t1", {
name: "x",
members: [],
projects: [{ projectId: "p1", permission: "manage" }],
})
).rejects.toThrow();
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(updateTeamDetails("t1", data)).rejects.toThrow(DatabaseError);
});
});

View File

@@ -57,7 +57,7 @@ export const getTeamsByOrganizationId = reactCache(
)()
);
const getUserTeams = reactCache(
export const getUserTeams = reactCache(
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
cache(
async () => {

View File

@@ -0,0 +1,67 @@
import { ProjectTeamPermission, TeamUserRole } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { TeamPermissionMapping, TeamRoleMapping, getTeamAccessFlags, getTeamPermissionFlags } from "./teams";
describe("TeamPermissionMapping", () => {
test("maps ProjectTeamPermission to correct labels", () => {
expect(TeamPermissionMapping[ProjectTeamPermission.read]).toBe("Read");
expect(TeamPermissionMapping[ProjectTeamPermission.readWrite]).toBe("Read & write");
expect(TeamPermissionMapping[ProjectTeamPermission.manage]).toBe("Manage");
});
});
describe("TeamRoleMapping", () => {
test("maps TeamUserRole to correct labels", () => {
expect(TeamRoleMapping[TeamUserRole.admin]).toBe("Team Admin");
expect(TeamRoleMapping[TeamUserRole.contributor]).toBe("Contributor");
});
});
describe("getTeamAccessFlags", () => {
test("returns correct flags for admin", () => {
expect(getTeamAccessFlags(TeamUserRole.admin)).toEqual({ isAdmin: true, isContributor: false });
});
test("returns correct flags for contributor", () => {
expect(getTeamAccessFlags(TeamUserRole.contributor)).toEqual({ isAdmin: false, isContributor: true });
});
test("returns false flags for undefined/null", () => {
expect(getTeamAccessFlags()).toEqual({ isAdmin: false, isContributor: false });
expect(getTeamAccessFlags(null)).toEqual({ isAdmin: false, isContributor: false });
});
});
describe("getTeamPermissionFlags", () => {
test("returns correct flags for read", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.read)).toEqual({
hasReadAccess: true,
hasReadWriteAccess: false,
hasManageAccess: false,
});
});
test("returns correct flags for readWrite", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.readWrite)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: true,
hasManageAccess: false,
});
});
test("returns correct flags for manage", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.manage)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: true,
});
});
test("returns all false for undefined/null", () => {
expect(getTeamPermissionFlags()).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: false,
});
expect(getTeamPermissionFlags(null)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: false,
});
});
});

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

@@ -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

@@ -88,7 +88,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
<source src={`${key}`} type="video/mp4" />
</video>
<input
className="absolute top-2 right-2 h-4 w-4 rounded-sm bg-white"
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white"
type="checkbox"
checked={animation === value}
onChange={() => handleBg(value)}

View File

@@ -67,7 +67,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "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",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />

View File

@@ -178,7 +178,7 @@ export const FileUploadQuestionForm = ({
</Button>
)}
</div>
<div className="mt-6 mb-8 space-y-6">
<div className="mb-8 mt-6 space-y-6">
<AdvancedOptionToggle
isChecked={question.allowMultipleFiles}
onToggle={() => updateQuestion(questionIdx, { allowMultipleFiles: !question.allowMultipleFiles })}
@@ -218,7 +218,7 @@ export const FileUploadQuestionForm = ({
updateQuestion(questionIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
}}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>

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

@@ -232,7 +232,7 @@ const getPlaceholderByInputType = (inputType: TSurveyOpenTextQuestionInputType)
case "email":
return "example@email.com";
case "url":
return "http://...";
return "https://...";
case "number":
return "42";
case "phone":

View File

@@ -196,7 +196,7 @@ export const QuestionCard = ({
)}>
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
<button className="opacity-0 group-hover:opacity-100 hover:cursor-move">
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
<GripIcon className="h-4 w-4" />
</button>
</div>

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

@@ -318,7 +318,7 @@ export const ResponseOptionsCard = ({
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<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"
@@ -356,7 +356,7 @@ export const ResponseOptionsCard = ({
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.completed_responses")}
</p>
@@ -451,7 +451,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
@@ -506,7 +506,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
name="heading"
value={singleUseMessage.heading}
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
@@ -514,7 +514,7 @@ export const ResponseOptionsCard = ({
<Label htmlFor="headline">{t("environments.surveys.edit.subheading")}</Label>
<Input
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
id="subheading"
name="subheading"
value={singleUseMessage.subheading}

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

@@ -91,7 +91,7 @@ export const SurveyPlacementCard = ({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<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

@@ -41,7 +41,7 @@ export const SurveyVariablesCard = ({
<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"
)}>
<div className="flex w-full justify-center">
<FileDigitIcon className="h-4 w-4" />
@@ -75,7 +75,7 @@ export const SurveyVariablesCard = ({
/>
))
) : (
<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_variables_yet_add_first_one_below")}
</p>
)}

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

@@ -75,7 +75,7 @@ const FollowUpActionMultiEmailInput = ({
<span className="text-slate-900">{email}</span>
<button
onClick={() => removeEmail(index)}
className="px-1 text-lg leading-none font-medium text-slate-500">
className="px-1 text-lg font-medium leading-none text-slate-500">
×
</button>
</div>

View File

@@ -98,11 +98,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
field.onChange([...field.value, environment.id]);
}
}}
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium text-slate-900 capitalize">
<p className="text-sm font-medium capitalize text-slate-900">
{environment.type}
</p>
</Label>
@@ -121,8 +121,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
);
})}
</div>
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pr-4 pb-4">
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>

View File

@@ -32,7 +32,7 @@ vi.mock("@/modules/ui/components/checkbox", () => ({
id={id}
data-testid={id}
name={props.name}
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
onChange={() => {
// Call onCheckedChange with true to simulate checkbox selection
onCheckedChange(true);

View File

@@ -37,7 +37,7 @@ export const TemplateContainerWithPreview = ({
<MenuBar />
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<div className="mt-6 mb-3 ml-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<h1 className="text-2xl font-bold text-slate-800">
{t("environments.surveys.templates.create_a_new_survey")}
</h1>

View File

@@ -57,7 +57,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
return (
<h3
ref={ref}
className={cn("text-2xl leading-none font-semibold tracking-tight", className)}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}>
{headingContent}
</h3>

View File

@@ -13,7 +13,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
blur && "backdrop-blur-md",
"bg-opacity-30 fixed inset-0 z-50",
"fixed inset-0 z-50 bg-opacity-30",
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn"
)}
{...props}
@@ -58,8 +58,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%] transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-2 sm:w-full sm:max-w-xl",
`${noPadding ? "" : "px-4 pt-5 pb-4 sm:p-6"}`,
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] transform rounded-lg bg-white text-left shadow-xl transition-all sm:my-2 sm:w-full sm:max-w-xl",
`${noPadding ? "" : "px-4 pb-4 pt-5 sm:p-6"}`,
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn",
size && sizeClassName && sizeClassName[size],
!restrictOverflow && "overflow-hidden",
@@ -78,7 +78,7 @@ const DialogContent = React.forwardRef<
{children}
<DialogPrimitive.Close
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",
hideCloseButton && "!hidden"
)}>
<XIcon className="h-6 w-6 rounded-md bg-white" />

View File

@@ -246,10 +246,10 @@ export const PreviewSurvey = ({
className="relative flex h-full w-[95%] items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
{previewMode === "mobile" && (
<>
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute top-0 right-0 m-2">
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton onClick={resetQuestionProgress} />
</div>
<MediaBackground
@@ -284,7 +284,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)}
@@ -392,7 +392,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute top-5 left-5">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)}

View File

@@ -51,7 +51,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
const loadScript = async () => {
if (!window.formbricksSurveys) {
try {
if (props.isSpamProtectionEnabled) {
if (props.isSpamProtectionEnabled && props.recaptchaSiteKey) {
await loadRecaptchaScript(props.recaptchaSiteKey);
}
await loadSurveyScript();

View File

@@ -1,6 +1,7 @@
{
"name": "@formbricks/web",
"version": "0.0.0",
"packageManager": "pnpm@9.15.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next coverage",
@@ -41,14 +42,13 @@
"@lexical/react": "0.31.0",
"@lexical/rich-text": "0.31.0",
"@lexical/table": "0.31.0",
"@opentelemetry/api-logs": "0.56.0",
"@opentelemetry/exporter-prometheus": "0.57.2",
"@opentelemetry/host-metrics": "0.35.5",
"@opentelemetry/instrumentation": "0.57.2",
"@opentelemetry/instrumentation-http": "0.57.2",
"@opentelemetry/instrumentation-runtime-node": "0.12.2",
"@opentelemetry/sdk-logs": "0.56.0",
"@opentelemetry/sdk-metrics": "1.30.1",
"@opentelemetry/exporter-prometheus": "0.200.0",
"@opentelemetry/host-metrics": "0.36.0",
"@opentelemetry/instrumentation": "0.200.0",
"@opentelemetry/instrumentation-http": "0.200.0",
"@opentelemetry/instrumentation-runtime-node": "0.14.0",
"@opentelemetry/sdk-logs": "0.200.0",
"@opentelemetry/sdk-metrics": "2.0.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.7.0",
"@radix-ui/react-accordion": "1.2.9",
@@ -74,15 +74,12 @@
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@testing-library/jest-dom": "6.6.3",
"@tolgee/cli": "2.10.2",
"@tolgee/format-icu": "6.2.4",
"@tolgee/react": "6.2.4",
"@ungap/structured-clone": "1.3.0",
"@unkey/ratelimit": "0.5.5",
"@vercel/functions": "2.0.1",
"@vercel/og": "0.6.8",
"autoprefixer": "10.4.21",
"bcryptjs": "3.0.2",
"boring-avatars": "1.11.2",
"class-variance-authority": "0.7.1",
@@ -90,7 +87,6 @@
"cmdk": "1.1.1",
"csv-parse": "5.6.0",
"date-fns": "4.1.0",
"dotenv": "16.5.0",
"file-loader": "6.2.0",
"framer-motion": "12.9.7",
"googleapis": "148.0.0",
@@ -112,7 +108,6 @@
"nodemailer": "7.0.2",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"postcss": "8.5.3",
"posthog-js": "1.239.1",
"posthog-node": "4.17.1",
"prismjs": "1.30.0",
@@ -138,14 +133,16 @@
"uuid": "11.1.0",
"webpack": "5.99.7",
"xlsx": "0.18.5",
"zod": "3.24.1",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@neshca/cache-handler": "1.9.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@tolgee/cli": "2.10.2",
"@types/bcryptjs": "2.4.6",
"@types/heic-convert": "2.1.0",
"@types/jsonwebtoken": "9.0.9",
@@ -158,7 +155,9 @@
"@types/testing-library__react": "10.2.0",
"@types/ungap__structured-clone": "1.2.0",
"@vitest/coverage-v8": "3.1.3",
"autoprefixer": "10.4.21",
"dotenv": "16.5.0",
"postcss": "8.5.3",
"ts-node": "10.9.2",
"resize-observer-polyfill": "1.5.1",
"vite": "6.3.5",

View File

@@ -126,6 +126,8 @@ export default defineConfig({
"modules/survey/editor/components/file-upload-question-form.tsx",
"modules/survey/editor/components/how-to-send-card.tsx",
"modules/survey/editor/components/image-survey-bg.tsx",
"modules/ee/teams/**/*.ts",
"modules/ee/teams/**/*.tsx",
"app/(app)/environments/**/*.tsx",
"app/(app)/environments/**/*.ts",
],

View File

@@ -3,17 +3,17 @@
"version": "0.0.0",
"private": true,
"devDependencies": {
"@next/eslint-plugin-next": "15.3.0",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@next/eslint-plugin-next": "15.3.1",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vercel/style-guide": "6.0.0",
"eslint-config-next": "15.3.0",
"eslint-config-prettier": "10.1.1",
"eslint-config-turbo": "2.5.0",
"eslint-config-next": "15.3.1",
"eslint-config-prettier": "10.1.2",
"eslint-config-turbo": "2.5.2",
"eslint-plugin-i18n-json": "4.0.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"@vitest/eslint-plugin": "1.1.42"
"eslint-plugin-react-refresh": "0.4.20",
"@vitest/eslint-plugin": "1.1.44"
}
}

View File

@@ -7,8 +7,8 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "22.15.3",
"@types/react": "19.1.2",
"@types/node": "22.15.12",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"typescript": "5.8.3"
}

View File

@@ -1,5 +1,6 @@
{
"name": "@formbricks/database",
"packageManager": "pnpm@9.15.0",
"private": true,
"version": "0.1.0",
"main": "./src/index.ts",
@@ -24,18 +25,18 @@
},
"dependencies": {
"@formbricks/logger": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.7.0",
"@prisma/extension-accelerate": "1.3.0",
"dotenv-cli": "8.0.0",
"zod-openapi": "4.2.4"
"zod-openapi": "4.2.4",
"zod": "3.24.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"dotenv-cli": "8.0.0",
"prisma": "6.7.0",
"prisma-json-types-generator": "3.2.3",
"ts-node": "10.9.2",
"zod": "3.24.1"
"prisma-json-types-generator": "3.3.1",
"ts-node": "10.9.2"
}
}

View File

@@ -30,7 +30,6 @@
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
"build": "tsc && vite build"
},
"peerDependencies": {},
"devDependencies": {
"vite": "6.3.5",
"@formbricks/config-typescript": "workspace:*",

View File

@@ -399,16 +399,27 @@ describe("utils.ts", () => {
// ---------------------------------------------------------------------------------
describe("shouldDisplayBasedOnPercentage()", () => {
test("returns true if random number <= displayPercentage", () => {
// We'll mock Math.random to return something
const mockedRandom = vi.spyOn(Math, "random").mockReturnValue(0.2); // 0.2 => 20%
// displayPercentage = 30 => 30% => we should display
const mockGetRandomValues = vi
.spyOn(crypto, "getRandomValues")
.mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array) {
array[0] = Math.floor((20 / 100) * 2 ** 32);
return array;
}
return array;
});
expect(shouldDisplayBasedOnPercentage(30)).toBe(true);
mockedRandom.mockReturnValue(0.5); // 50%
mockGetRandomValues.mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array) {
array[0] = Math.floor((80 / 100) * 2 ** 32);
return array;
}
return array;
});
expect(shouldDisplayBasedOnPercentage(30)).toBe(false);
// restore
mockedRandom.mockRestore();
mockGetRandomValues.mockRestore();
});
});

View File

@@ -183,8 +183,14 @@ export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: stri
return selectedLanguage.language.code;
};
export const getSecureRandom = (): number => {
const u32 = new Uint32Array(1);
crypto.getRandomValues(u32);
return u32[0] / 2 ** 32; // Normalized to [0, 1)
};
export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
const randomNum = Math.floor(Math.random() * 10000) / 100; // NOSONAR typescript:S2245 // Math.random() is not used in a security context
const randomNum = Math.floor(getSecureRandom() * 10000) / 100;
return randomNum <= displayPercentage;
};

View File

@@ -96,7 +96,7 @@ export const renderWidget = async (
return executeRecaptcha(recaptchaSiteKey);
};
if (isSpamProtectionEnabled) {
if (isSpamProtectionEnabled && recaptchaSiteKey) {
await loadRecaptchaScript(recaptchaSiteKey);
}

View File

@@ -35,9 +35,9 @@
},
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"zod": "3.24.2",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
"zod": "3.24.4",
"pino": "9.6.0",
"pino-pretty": "13.0.0"
},
"devDependencies": {
"vite": "6.3.5",

View File

@@ -37,30 +37,30 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@calcom/embed-snippet": "1.3.3",
"@formkit/auto-animate": "0.8.2",
"isomorphic-dompurify": "2.24.0",
"preact": "10.26.5",
"react-date-picker": "11.0.0",
"react-calendar": "5.1.0"
},
"devDependencies": {
"@calcom/embed-snippet": "1.3.2",
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "2.10.1",
"@testing-library/preact": "3.2.4",
"@types/react": "19.1.0",
"@types/react": "19.1.3",
"autoprefixer": "10.4.21",
"concurrently": "9.1.2",
"isomorphic-dompurify": "2.23.0",
"postcss": "8.5.3",
"preact": "10.26.5",
"react-date-picker": "11.0.0",
"serve": "14.2.4",
"tailwindcss": "3.4.16",
"terser": "5.39.0",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
},
"dependencies": {
"@formkit/auto-animate": "0.8.2",
"react-calendar": "5.1.0"
}
}

View File

@@ -41,16 +41,16 @@ export function LanguageSwitch({
});
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-pr-1">
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center fb-pr-1">
<button
title="Language switch"
type="button"
className="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
className="fb-text-heading fb-relative fb-h-6 fb-w-6 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
<GlobeIcon className="fb-text-heading fb-h-5 fb-w-5 fb-p-0.5" />
<GlobeIcon className="fb-text-heading fb-h-6 fb-w-6 fb-p-0.5" />
</button>
{showLanguageDropdown ? (
<div

View File

@@ -15,9 +15,15 @@ export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
export const getSecureRandom = (): number => {
const u32 = new Uint32Array(1);
crypto.getRandomValues(u32);
return u32[0] / 2 ** 32; // Normalized to [0, 1)
};
const shuffle = (array: unknown[]) => {
for (let i = 0; i < array.length; i++) {
const j = Math.floor(Math.random() * (i + 1)); // NOSONAR typescript:S2245 // Math.random() is not used in a security context
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(getSecureRandom() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
};

View File

@@ -8,12 +8,12 @@
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
"@prisma/client": "6.7.0",
"zod": "3.24.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/database": "workspace:*"
},
"dependencies": {
"@prisma/client": "6.7.0",
"zod": "3.24.1"
}
}

1700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff