Compare commits

..

6 Commits

Author SHA1 Message Date
Matti Nannt 122de0121e fix: address vendored minimatch review comments (#7608) 2026-03-30 13:52:02 +02:00
Matti Nannt eb416d7ff7 fix: resolve dependabot vulnerability alerts 2026-03-27 09:18:16 +01:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
542 changed files with 23583 additions and 18109 deletions
+5 -9
View File
@@ -38,15 +38,6 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################ ################
# MAIL SETUP # # MAIL SETUP #
################ ################
@@ -194,6 +185,11 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace"; import { TProjectConfigChannel } from "@formbricks/types/project";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
environment: TEnvironment; environment: TEnvironment;
publicDomain: string; publicDomain: string;
appSetupCompleted: boolean; appSetupCompleted: boolean;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
} }
export const ConnectWithFormbricks = ({ export const ConnectWithFormbricks = ({
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace"; import { TProjectConfigChannel } from "@formbricks/types/project";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block"; import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons"; import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -19,7 +19,7 @@ const tabs = [
interface OnboardingSetupInstructionsProps { interface OnboardingSetupInstructionsProps {
environmentId: string; environmentId: string;
publicDomain: string; publicDomain: string;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
appSetupCompleted: boolean; appSetupCompleted: boolean;
} }
@@ -4,7 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
@@ -24,12 +24,12 @@ const Page = async (props: ConnectPageProps) => {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
const workspace = await getWorkspaceByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!workspace) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const channel = workspace.config.channel || null; const channel = project.config.channel || null;
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types"; import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils"; import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates"; import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions"; import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps { interface XMTemplateListProps {
workspace: TWorkspace; project: TProject;
user: TUser; user: TUser;
environmentId: string; environmentId: string;
} }
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => { export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null); const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateLis
const handleTemplateClick = (templateIdx: number) => { const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx); setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx]; const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, workspace); const newTemplate = replacePresetPlaceholders(template, project);
createSurvey(newTemplate); createSurvey(newTemplate);
}; };
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
// Mock data // Mock data
const mockWorkspace: TWorkspace = { const mockProject: TProject = {
id: "workspace1", id: "project1",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
name: "Test Workspace", name: "Test Project",
organizationId: "org1", organizationId: "org1",
styling: { styling: {
allowStyleOverwrite: true, allowStyleOverwrite: true,
@@ -32,7 +32,7 @@ const mockWorkspace: TWorkspace = {
logo: null, logo: null,
}; };
const mockTemplate: TXMTemplate = { const mockTemplate: TXMTemplate = {
name: "$[workspaceName] Survey", name: "$[projectName] Survey",
blocks: [ blocks: [
{ {
id: "block1", id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1", id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText, type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[workspaceName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup(); cleanup();
}); });
test("replaces workspaceName placeholder in template name", () => { test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Workspace Survey"); expect(result.name).toBe("Test Project Survey");
}); });
test("replaces workspaceName placeholder in element headline", () => { test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question"); expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
}); });
test("returns a new object without mutating the original template", () => { test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate); const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result).not.toBe(mockTemplate); expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate); expect(mockTemplate).toEqual(originalTemplate);
}); });
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates"; import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of workspaceName with the actual workspace name in the current template // replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => { export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
const survey = structuredClone(template); const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({ const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block, ...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)), elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
})); }));
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks }; return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
}; };
@@ -4,9 +4,9 @@ import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -37,18 +37,18 @@ const Page = async (props: XMTemplatePageProps) => {
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const workspace = await getWorkspaceByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!workspace) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const workspaces = await getUserWorkspaces(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} /> <Header title={t("environments.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{workspaces.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -2,7 +2,7 @@ import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service"; import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: { const LandingLayout = async (props: {
@@ -24,11 +24,11 @@ const LandingLayout = async (props: {
return notFound(); return notFound();
} }
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
if (workspaces.length !== 0) { if (projects.length !== 0) {
const firstWorkspace = workspaces[0]; const firstProject = projects[0];
const environments = await getEnvironments(firstWorkspace.id); const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production"); const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) { if (prodEnvironment) {
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch"; import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -34,12 +34,12 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1"> <div className="flex-1">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="p-6"> <div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */} {/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<WorkspaceAndOrgSwitch <ProjectAndOrgSwitch
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentOrganizationName={organization.name} currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={0} organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false} isLicenseActive={false}
isOwnerOrManager={false} isOwnerOrManager={false}
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const WorkspaceOnboardingLayout = async (props: { const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
@@ -47,4 +47,4 @@ const WorkspaceOnboardingLayout = async (props: {
); );
}; };
export default WorkspaceOnboardingLayout; export default ProjectOnboardingLayout;
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
}, },
]; ];
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")} subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/> />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([ const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id), getOrganizationProjectsLimit(organization.id),
getOrganizationWorkspacesCount(organization.id), getOrganizationProjectsCount(organization.id),
]); ]);
if (organizationWorkspacesCount >= organizationWorkspacesLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);
} }
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
}, },
]; ];
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} /> <Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
TWorkspaceConfigChannel, TProjectConfigChannel,
TWorkspaceConfigIndustry, TProjectConfigIndustry,
TWorkspaceMode, TProjectMode,
TWorkspaceUpdateInput, TProjectUpdateInput,
ZWorkspaceUpdateInput, ZProjectUpdateInput,
} from "@formbricks/types/workspace"; } from "@formbricks/types/project";
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions"; import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates"; import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants"; import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker"; import { ColorPicker } from "@/modules/ui/components/color-picker";
import { import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select"; import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey"; import { SurveyInline } from "@/modules/ui/components/survey";
interface WorkspaceSettingsProps { interface ProjectSettingsProps {
organizationId: string; organizationId: string;
workspaceMode: TWorkspaceMode; projectMode: TProjectMode;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
industry: TWorkspaceConfigIndustry; industry: TProjectConfigIndustry;
defaultBrandColor: string; defaultBrandColor: string;
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
userWorkspacesCount: number; userProjectsCount: number;
publicDomain: string; publicDomain: string;
} }
export const WorkspaceSettings = ({ export const ProjectSettings = ({
organizationId, organizationId,
workspaceMode, projectMode,
channel, channel,
industry, industry,
defaultBrandColor, defaultBrandColor,
organizationTeams, organizationTeams,
isAccessControlAllowed = false, isAccessControlAllowed = false,
userWorkspacesCount, userProjectsCount,
publicDomain, publicDomain,
}: WorkspaceSettingsProps) => { }: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const addWorkspace = async (data: TWorkspaceUpdateInput) => { const addProject = async (data: TProjectUpdateInput) => {
try { try {
// Build the full styling from the chosen brand color so all derived // Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted. // colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const WorkspaceSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b). // back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light); const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createWorkspaceResponse = await createWorkspaceAction({ const createProjectResponse = await createProjectAction({
organizationId, organizationId,
data: { data: {
...data, ...data,
@@ -81,14 +81,14 @@ export const WorkspaceSettings = ({
}, },
}); });
if (createWorkspaceResponse?.data) { if (createProjectResponse?.data) {
// get production environment // get production environment
const productionEnvironment = createWorkspaceResponse.data.environments.find( const productionEnvironment = createProjectResponse.data.environments.find(
(environment: { type: string }) => environment.type === "production" (environment) => environment.type === "production"
); );
if (productionEnvironment) { if (productionEnvironment) {
if (globalThis.window !== undefined) { if (globalThis.window !== undefined) {
// Remove filters when creating a new workspace // Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
} }
} }
@@ -96,11 +96,11 @@ export const WorkspaceSettings = ({
router.push(`/environments/${productionEnvironment?.id}/connect`); router.push(`/environments/${productionEnvironment?.id}/connect`);
} else if (channel === "link") { } else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`); router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (workspaceMode === "cx") { } else if (projectMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`); router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
} }
} else { } else {
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse); const errorMessage = getFormattedErrorMessage(createProjectResponse);
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (error) { } catch (error) {
@@ -109,15 +109,15 @@ export const WorkspaceSettings = ({
} }
}; };
const form = useForm<TWorkspaceUpdateInput>({ const form = useForm<TProjectUpdateInput>({
defaultValues: { defaultValues: {
name: "", name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } }, styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [], teamIds: [],
}, },
resolver: zodResolver(ZWorkspaceUpdateInput), resolver: zodResolver(ZProjectUpdateInput),
}); });
const workspaceName = form.watch("name"); const projectName = form.watch("name");
const logoUrl = form.watch("logo.url"); const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor; const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]); const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +132,7 @@ export const WorkspaceSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2"> <div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4"> <div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4"> <form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="styling.brandColor.light" name="styling.brandColor.light"
@@ -184,7 +184,7 @@ export const WorkspaceSettings = ({
)} )}
/> />
{isAccessControlAllowed && userWorkspacesCount > 0 && ( {isAccessControlAllowed && userProjectsCount > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="teamIds" name="teamIds"
@@ -242,7 +242,7 @@ export const WorkspaceSettings = ({
<SurveyInline <SurveyInline
appUrl={publicDomain} appUrl={publicDomain}
isPreviewMode={true} isPreviewMode={true}
survey={previewSurvey(workspaceName || t("common.my_product"), t)} survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling} styling={previewStyling}
isBrandingEnabled={false} isBrandingEnabled={false}
languageCode="default" languageCode="default"
@@ -2,34 +2,30 @@ import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
interface WorkspaceSettingsPageProps { interface ProjectSettingsPageProps {
params: Promise<{ params: Promise<{
organizationId: string; organizationId: string;
}>; }>;
searchParams: Promise<{ searchParams: Promise<{
channel?: TWorkspaceConfigChannel; channel?: TProjectConfigChannel;
industry?: TWorkspaceConfigIndustry; industry?: TProjectConfigIndustry;
mode?: TWorkspaceMode; mode?: TProjectMode;
}>; }>;
} }
const Page = async (props: WorkspaceSettingsPageProps) => { const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -43,7 +39,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
const channel = searchParams.channel ?? null; const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null; const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys"; const mode = searchParams.mode ?? "surveys";
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -61,18 +57,18 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")} title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")} subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/> />
<WorkspaceSettings <ProjectSettings
organizationId={params.organizationId} organizationId={params.organizationId}
workspaceMode={mode} projectMode={mode}
channel={channel} channel={channel}
industry={industry} industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR} defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
userWorkspacesCount={workspaces.length} userProjectsCount={projects.length}
publicDomain={publicDomain} publicDomain={publicDomain}
/> />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
@@ -7,29 +7,29 @@ import {
OperationNotAllowedError, OperationNotAllowedError,
ResourceNotFoundError, ResourceNotFoundError,
} from "@formbricks/types/errors"; } from "@formbricks/types/errors";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
getOrganizationWorkspacesLimit, getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace"; import { createProject } from "@/modules/projects/settings/lib/project";
import { getOrganizationsByUserId } from "./lib/organization"; import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace"; import { getProjectsByUserId } from "./lib/project";
const ZCreateWorkspaceAction = z.object({ const ZCreateProjectAction = z.object({
organizationId: ZId, organizationId: ZId,
data: ZWorkspaceUpdateInput, data: ZProjectUpdateInput,
}); });
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action( export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => { withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const { user } = ctx; const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
@@ -40,7 +40,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZWorkspaceUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
@@ -53,10 +53,10 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
throw new ResourceNotFoundError("Organization", organizationId); throw new ResourceNotFoundError("Organization", organizationId);
} }
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationWorkspacesCount >= organizationWorkspacesLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached"); throw new OperationNotAllowedError("Organization workspace limit reached");
} }
@@ -68,7 +68,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
} }
} }
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data); const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = { const updatedNotificationSettings = {
...user.notificationSettings, ...user.notificationSettings,
alert: { alert: {
@@ -81,9 +81,9 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
}); });
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspace.id; ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = workspace; ctx.auditLoggingCtx.newObject = project;
return workspace; return project;
}) })
); );
@@ -112,16 +112,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id); return await getOrganizationsByUserId(ctx.user.id);
}); });
const ZGetWorkspacesForSwitcherAction = z.object({ const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query organizationId: ZId, // Changed from environmentId to avoid extra query
}); });
/** /**
* Fetches projects list for switcher dropdown. * Fetches projects list for switcher dropdown.
* Called on-demand when user opens the workspace switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getWorkspacesForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction) .inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -134,11 +134,11 @@ export const getWorkspacesForSwitcherAction = authenticatedActionClient
], ],
}); });
// Need membership for getWorkspacesByUserId (1 DB query) // Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) { if (!membership) {
throw new AuthorizationError("Membership not found"); throw new AuthorizationError("Membership not found");
} }
return await getWorkspacesByUserId(ctx.user.id, membership); return await getProjectsByUserId(ctx.user.id, membership);
}); });
@@ -5,7 +5,7 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth"; import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
@@ -25,10 +25,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment, environment,
organization, organization,
membership, membership,
workspace, // Current workspace details project, // Current project details
environments, // All workspace environments (for environment switcher) environments, // All project environments (for environment switcher)
isAccessControlAllowed, isAccessControlAllowed,
workspacePermission, projectPermission,
license, license,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that workspace permission exists for members // Validate that project permission exists for members
if (isMember && !workspacePermission) { if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
return ( return (
@@ -70,7 +70,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
environment={environment} environment={environment}
organization={organization} organization={organization}
user={user} user={user}
workspace={{ id: workspace.id, name: workspace.name }} project={{ id: project.id, name: project.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
@@ -80,9 +80,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<TopControlBar <TopControlBar
environments={environments} environments={environments}
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentWorkspaceId={workspace.id} currentProjectId={project.id}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active} isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
@@ -29,6 +29,7 @@ import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert"; import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -37,14 +38,13 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json"; import packageJson from "../../../../../package.json";
interface NavigationProps { interface NavigationProps {
environment: TEnvironment; environment: TEnvironment;
user: TUser; user: TUser;
organization: TOrganization; organization: TOrganization;
workspace: { id: string; name: string }; project: { id: string; name: string };
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
@@ -55,7 +55,7 @@ export const MainNavigation = ({
environment, environment,
organization, organization,
user, user,
workspace, project,
membershipRole, membershipRole,
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
@@ -92,7 +92,7 @@ export const MainNavigation = ({
}, [isCollapsed]); }, [isCollapsed]);
useEffect(() => { useEffect(() => {
// Auto collapse workspace navbar on org and account settings // Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) { if (pathname?.includes("/settings")) {
setIsCollapsed(true); setIsCollapsed(true);
} }
@@ -186,7 +186,7 @@ export const MainNavigation = ({
return ( return (
<> <>
{workspace && ( {project && (
<aside <aside
className={cn( className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100", "z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
@@ -1,13 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { ReactNode } from "react"; import { ReactNode } from "react";
interface WorkspaceNavItemProps { interface ProjectNavItemProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
isActive: boolean; isActive: boolean;
} }
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => { export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold"; const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50"; const inactiveClass = "hover:bg-slate-50";
@@ -2,16 +2,16 @@
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch"; import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps { interface TopControlBarProps {
environments: TEnvironment[]; environments: TEnvironment[];
currentOrganizationId: string; currentOrganizationId: string;
currentWorkspaceId: string; currentProjectId: string;
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
@@ -22,9 +22,9 @@ interface TopControlBarProps {
export const TopControlBar = ({ export const TopControlBar = ({
environments, environments,
currentOrganizationId, currentOrganizationId,
currentWorkspaceId, currentProjectId,
isMultiOrgEnabled, isMultiOrgEnabled,
organizationWorkspacesLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
isLicenseActive, isLicenseActive,
isOwnerOrManager, isOwnerOrManager,
@@ -38,13 +38,13 @@ export const TopControlBar = ({
<div <div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6" className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar"> data-testid="fb__global-top-control-bar">
<WorkspaceAndOrgSwitch <ProjectAndOrgSwitch
currentEnvironmentId={environment.id} currentEnvironmentId={environment.id}
environments={environments} environments={environments}
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
currentWorkspaceId={currentWorkspaceId} currentProjectId={currentProjectId}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
@@ -138,12 +138,6 @@ export const OrganizationBreadcrumb = ({
label: t("common.members_and_teams"), label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`, href: `/environments/${currentEnvironmentId}/settings/teams`,
}, },
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
hidden: isMember,
},
{ {
id: "api-keys", id: "api-keys",
label: t("common.api_keys"), label: t("common.api_keys"),
@@ -2,18 +2,18 @@
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb"; import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb"; import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb"; import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb"; import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface WorkspaceAndOrgSwitchProps { interface ProjectAndOrgSwitchProps {
currentOrganizationId: string; currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context currentOrganizationName?: string; // Optional: for pages without context
currentWorkspaceId?: string; currentProjectId?: string;
currentWorkspaceName?: string; // Optional: for pages without context currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string; currentEnvironmentId?: string;
environments: { id: string; type: string }[]; environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
@@ -21,21 +21,21 @@ interface WorkspaceAndOrgSwitchProps {
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
} }
export const WorkspaceAndOrgSwitch = ({ export const ProjectAndOrgSwitch = ({
currentOrganizationId, currentOrganizationId,
currentOrganizationName, currentOrganizationName,
currentWorkspaceId, currentProjectId,
currentWorkspaceName, currentProjectName,
currentEnvironmentId, currentEnvironmentId,
environments, environments,
isMultiOrgEnabled, isMultiOrgEnabled,
organizationWorkspacesLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
isLicenseActive, isLicenseActive,
isOwnerOrManager, isOwnerOrManager,
isAccessControlAllowed, isAccessControlAllowed,
isMember, isMember,
}: WorkspaceAndOrgSwitchProps) => { }: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId); const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development"; const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -51,14 +51,14 @@ export const WorkspaceAndOrgSwitch = ({
isMember={isMember} isMember={isMember}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
/> />
{currentWorkspaceId && currentEnvironmentId && ( {currentProjectId && currentEnvironmentId && (
<WorkspaceBreadcrumb <ProjectBreadcrumb
currentWorkspaceId={currentWorkspaceId} currentProjectId={currentProjectId}
currentWorkspaceName={currentWorkspaceName} currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId} currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
organizationWorkspacesLimit={organizationWorkspacesLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
@@ -6,8 +6,10 @@ import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions"; import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb"; import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import { import {
DropdownMenu, DropdownMenu,
@@ -18,15 +20,13 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt"; import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal"; import { useProject } from "../context/environment-context";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/environment-context";
interface WorkspaceBreadcrumbProps { interface ProjectBreadcrumbProps {
currentWorkspaceId: string; currentProjectId: string;
currentWorkspaceName?: string; // Optional: pass directly if context not available currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
organizationWorkspacesLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isLicenseActive: boolean; isLicenseActive: boolean;
currentOrganizationId: string; currentOrganizationId: string;
@@ -35,7 +35,7 @@ interface WorkspaceBreadcrumbProps {
isEnvironmentBreadcrumbVisible: boolean; isEnvironmentBreadcrumbVisible: boolean;
} }
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => { const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths // Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) { if (pathname.includes("/settings/")) {
return false; return false;
@@ -45,59 +45,59 @@ const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean
return pattern.test(pathname); return pattern.test(pathname);
}; };
export const WorkspaceBreadcrumb = ({ export const ProjectBreadcrumb = ({
currentWorkspaceId, currentProjectId,
currentWorkspaceName, currentProjectName,
isOwnerOrManager, isOwnerOrManager,
organizationWorkspacesLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
isLicenseActive, isLicenseActive,
currentOrganizationId, currentOrganizationId,
currentEnvironmentId, currentEnvironmentId,
isAccessControlAllowed, isAccessControlAllowed,
isEnvironmentBreadcrumbVisible, isEnvironmentBreadcrumbVisible,
}: WorkspaceBreadcrumbProps) => { }: ProjectBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false); const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false); const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false); const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter(); const router = useRouter();
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false); const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]); const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const pathname = usePathname(); const pathname = usePathname();
// Get current workspace name from context OR prop // Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper // Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { workspace: currentWorkspace } = useWorkspace(); const { project: currentProject } = useProject();
const workspaceName = currentWorkspace?.name || currentWorkspaceName || ""; const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load workspaces when dropdown opens // Lazy-load projects when dropdown opens
useEffect(() => { useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state) // Only fetch when dropdown opened for first time (and no error state)
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) { if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingWorkspaces(true); setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors setLoadError(null); // Clear any previous errors
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort workspaces by name // Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name)); const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setWorkspaces(sorted); setProjects(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result); const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage); const error = new Error(errorMessage);
logger.error(error, "Failed to load workspaces"); logger.error(error, "Failed to load projects");
Sentry.captureException(error); Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces")); setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
} }
setIsLoadingWorkspaces(false); setIsLoadingProjects(false);
}); });
} }
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]); }, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const workspaceSettings = [ const projectSettings = [
{ {
id: "general", id: "general",
label: t("common.general"), label: t("common.general"),
@@ -135,29 +135,29 @@ export const WorkspaceBreadcrumb = ({
}, },
]; ];
if (!currentWorkspace) { if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`; const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage); logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage)); Sentry.captureException(new Error(errorMessage));
return; return;
} }
const handleWorkspaceChange = (workspaceId: string) => { const handleProjectChange = (projectId: string) => {
if (workspaceId === currentWorkspaceId) return; if (projectId === currentProjectId) return;
startTransition(() => { startTransition(() => {
router.push(`/workspaces/${workspaceId}/`); router.push(`/workspaces/${projectId}/`);
}); });
}; };
const handleAddWorkspace = () => { const handleAddProject = () => {
if (workspaces.length >= organizationWorkspacesLimit) { if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true); setOpenLimitModal(true);
return; return;
} }
setOpenCreateWorkspaceModal(true); setOpenCreateProjectModal(true);
}; };
const handleWorkspaceSettingsNavigation = (settingId: string) => { const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => { startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`); router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
}); });
@@ -191,17 +191,17 @@ export const WorkspaceBreadcrumb = ({
]; ];
}; };
return ( return (
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}> <BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}> <DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger <DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none" className="flex cursor-pointer items-center gap-1 outline-none"
id="workspaceDropdownTrigger" id="projectDropdownTrigger"
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} /> <HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{workspaceName}</span> <span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? ( {isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} /> <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : ( ) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} /> <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
@@ -214,32 +214,32 @@ export const WorkspaceBreadcrumb = ({
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} /> <HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")} {t("common.choose_workspace")}
</div> </div>
{isLoadingWorkspaces && ( {isLoadingProjects && (
<div className="flex items-center justify-center py-2"> <div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
</div> </div>
)} )}
{!isLoadingWorkspaces && loadError && ( {!isLoadingProjects && loadError && (
<div className="px-2 py-4"> <div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p> <p className="mb-2 text-sm text-red-600">{loadError}</p>
<button <button
onClick={() => { onClick={() => {
setLoadError(null); setLoadError(null);
setWorkspaces([]); setProjects([]);
}} }}
className="text-xs text-slate-600 underline hover:text-slate-800"> className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")} {t("common.try_again")}
</button> </button>
</div> </div>
)} )}
{!isLoadingWorkspaces && !loadError && ( {!isLoadingProjects && !loadError && (
<> <>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto"> <DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => ( {projects.map((proj) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={proj.id} key={proj.id}
checked={proj.id === currentWorkspaceId} checked={proj.id === currentProjectId}
onClick={() => handleWorkspaceChange(proj.id)} onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer"> className="cursor-pointer">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{proj.name}</span> <span>{proj.name}</span>
@@ -249,7 +249,7 @@ export const WorkspaceBreadcrumb = ({
</DropdownMenuGroup> </DropdownMenuGroup>
{isOwnerOrManager && ( {isOwnerOrManager && (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onClick={handleAddWorkspace} onClick={handleAddProject}
className="w-full cursor-pointer justify-between"> className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span> <span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} /> <PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
@@ -263,11 +263,11 @@ export const WorkspaceBreadcrumb = ({
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} /> <CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")} {t("common.workspace_configuration")}
</div> </div>
{workspaceSettings.map((setting) => ( {projectSettings.map((setting) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={setting.id} key={setting.id}
checked={isActiveWorkspaceSetting(pathname, setting.id)} checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)} onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer"> className="cursor-pointer">
{setting.label} {setting.label}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
@@ -277,17 +277,17 @@ export const WorkspaceBreadcrumb = ({
</DropdownMenu> </DropdownMenu>
{/* Modals */} {/* Modals */}
{openLimitModal && ( {openLimitModal && (
<WorkspaceLimitModal <ProjectLimitModal
open={openLimitModal} open={openLimitModal}
setOpen={setOpenLimitModal} setOpen={setOpenLimitModal}
buttons={LimitModalButtons()} buttons={LimitModalButtons()}
workspaceLimit={organizationWorkspacesLimit} projectLimit={organizationProjectsLimit}
/> />
)} )}
{openCreateWorkspaceModal && ( {openCreateProjectModal && (
<CreateWorkspaceModal <CreateProjectModal
open={openCreateWorkspaceModal} open={openCreateProjectModal}
setOpen={setOpenCreateWorkspaceModal} setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId} organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
/> />
@@ -3,11 +3,11 @@
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TWorkspace } from "@formbricks/types/workspace"; import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType { export interface EnvironmentContextType {
environment: TEnvironment; environment: TEnvironment;
workspace: TWorkspace; project: TProject;
organization: TOrganization; organization: TOrganization;
organizationId: string; organizationId: string;
} }
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
return context; return context;
}; };
export const useWorkspace = () => { export const useProject = () => {
const context = useContext(EnvironmentContext); const context = useContext(EnvironmentContext);
if (!context) { if (!context) {
return { workspace: null }; return { project: null };
} }
return { workspace: context.workspace }; return { project: context.project };
}; };
export const useOrganization = () => { export const useOrganization = () => {
@@ -41,25 +41,25 @@ export const useOrganization = () => {
// Client wrapper component to be used in server components // Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps { interface EnvironmentContextWrapperProps {
environment: TEnvironment; environment: TEnvironment;
workspace: TWorkspace; project: TProject;
organization: TOrganization; organization: TOrganization;
children: React.ReactNode; children: React.ReactNode;
} }
export const EnvironmentContextWrapper = ({ export const EnvironmentContextWrapper = ({
environment, environment,
workspace, project,
organization, organization,
children, children,
}: EnvironmentContextWrapperProps) => { }: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo( const environmentContextValue = useMemo(
() => ({ () => ({
environment, environment,
workspace, project,
organization, organization,
organizationId: workspace.organizationId, organizationId: project.organizationId,
}), }),
[environment, workspace, organization] [environment, project, organization]
); );
return ( return (
@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper <EnvironmentContextWrapper
environment={layoutData.environment} environment={layoutData.environment}
workspace={layoutData.workspace} project={layoutData.project}
organization={layoutData.organization}> organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout> <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper> </EnvironmentContextWrapper>
@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { getWorkspacesByUserId } from "./workspace"; import { getProjectsByUserId } from "./project";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
workspace: { project: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
}, },
})); }));
describe("Workspace", () => { describe("Project", () => {
describe("getUserWorkspaces", () => { describe("getUserProjects", () => {
const mockAdminMembership: TMembership = { const mockAdminMembership: TMembership = {
role: "manager", role: "manager",
organizationId: "org1", organizationId: "org1",
@@ -29,17 +29,17 @@ describe("Workspace", () => {
accepted: true, accepted: true,
}; };
test("should return workspaces for admin role", async () => { test("should return projects for admin role", async () => {
const mockWorkspaces = [ const mockProjects = [
{ id: "workspace1", name: "Workspace 1" }, { id: "project1", name: "Project 1" },
{ id: "workspace2", name: "Workspace 2" }, { id: "project2", name: "Project 2" },
]; ];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockAdminMembership); const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
}, },
@@ -48,20 +48,20 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
test("should return workspaces for member role with team restrictions", async () => { test("should return projects for member role with team restrictions", async () => {
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockMemberMembership); const result = await getProjectsByUserId("user1", mockMemberMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -78,13 +78,13 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
test("should return empty array when no workspaces found", async () => { test("should return empty array when no projects found", async () => {
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getWorkspacesByUserId("user1", mockAdminMembership); const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -95,27 +95,27 @@ describe("Workspace", () => {
clientVersion: "5.0.0", clientVersion: "5.0.0",
}); });
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError); vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow( await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error") new DatabaseError("Database error")
); );
}); });
test("should re-throw unknown errors", async () => { test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error"); const unknownError = new Error("Unknown error");
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError); vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError); await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
}); });
test("should validate inputs correctly", async () => { test("should validate inputs correctly", async () => {
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow(); await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
}); });
test("should validate membership input correctly", async () => { test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership; const invalidMembership = {} as TMembership;
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow(); await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
}); });
test("should handle owner role like manager", async () => { test("should handle owner role like manager", async () => {
@@ -126,12 +126,12 @@ describe("Workspace", () => {
accepted: true, accepted: true,
}; };
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockOwnerMembership); const result = await getProjectsByUserId("user1", mockOwnerMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
}, },
@@ -140,7 +140,7 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
}); });
}); });
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships"; import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
export const getWorkspacesByUserId = reactCache( export const getProjectsByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => { async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]); validateInputs([userId, ZString], [orgMembership, ZMembership]);
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; let projectWhereClause: Prisma.ProjectWhereInput = {};
if (orgMembership.role === "member") { if (orgMembership.role === "member") {
workspaceWhereClause = { projectWhereClause = {
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -29,17 +29,17 @@ export const getWorkspacesByUserId = reactCache(
} }
try { try {
const workspaces = await prisma.workspace.findMany({ const projects = await prisma.project.findMany({
where: { where: {
organizationId: orgMembership.organizationId, organizationId: orgMembership.organizationId,
...workspaceWhereClause, ...projectWhereClause,
}, },
select: { select: {
id: true, id: true,
name: true, name: true,
}, },
}); });
return workspaces; return projects;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -14,9 +14,9 @@ const AccountSettingsLayout = async (props: {
const { children } = props; const { children } = props;
const t = await getTranslate(); const t = await getTranslate();
const [organization, workspace, session] = await Promise.all([ const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId), getOrganizationByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId), getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions), getServerSession(authOptions),
]); ]);
@@ -24,8 +24,8 @@ const AccountSettingsLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), null); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!workspace) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
@@ -40,7 +40,7 @@ export const EditAlerts = ({
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")} {t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
</p> </p>
<NotificationSwitch <NotificationSwitch
surveyOrWorkspaceOrOrganizationId={membership.organization.id} surveyOrProjectOrOrganizationId={membership.organization.id}
notificationSettings={user.notificationSettings!} notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedOrganizationIds"} notificationType={"unsubscribedOrganizationIds"}
autoDisableNotificationType={autoDisableNotificationType} autoDisableNotificationType={autoDisableNotificationType}
@@ -66,13 +66,13 @@ export const EditAlerts = ({
</TooltipProvider> </TooltipProvider>
</div> </div>
{membership.organization.workspaces.some((workspace) => {membership.organization.projects.some((project) =>
workspace.environments.some((environment) => environment.surveys.length > 0) project.environments.some((environment) => environment.surveys.length > 0)
) ? ( ) ? (
<div className="grid-cols-8 space-y-1 p-2"> <div className="grid-cols-8 space-y-1 p-2">
{membership.organization.workspaces.map((workspace) => ( {membership.organization.projects.map((project) => (
<div key={workspace.id}> <div key={project.id}>
{workspace.environments.map((environment) => ( {project.environments.map((environment) => (
<div key={environment.id}> <div key={environment.id}>
{environment.surveys.map((survey) => ( {environment.surveys.map((survey) => (
<div <div
@@ -80,11 +80,11 @@ export const EditAlerts = ({
key={survey.name}> key={survey.name}>
<div className="col-span-2 text-left"> <div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div> <div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div> <div className="text-xs text-slate-400">{project.name}</div>
</div> </div>
<div className="col-span-1 text-center"> <div className="col-span-1 text-center">
<NotificationSwitch <NotificationSwitch
surveyOrWorkspaceOrOrganizationId={survey.id} surveyOrProjectOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!} notificationSettings={user.notificationSettings!}
notificationType={"alert"} notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType} autoDisableNotificationType={autoDisableNotificationType}
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { updateNotificationSettingsAction } from "../actions"; import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps { interface NotificationSwitchProps {
surveyOrWorkspaceOrOrganizationId: string; surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings; notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "unsubscribedOrganizationIds"; notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string; autoDisableNotificationType?: string;
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
} }
export const NotificationSwitch = ({ export const NotificationSwitch = ({
surveyOrWorkspaceOrOrganizationId, surveyOrProjectOrOrganizationId,
notificationSettings, notificationSettings,
notificationType, notificationType,
autoDisableNotificationType, autoDisableNotificationType,
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
const router = useRouter(); const router = useRouter();
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true; : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
let updatedNotificationSettings = { ...notificationSettings }; let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedOrganizationIds") { if (notificationType === "unsubscribedOrganizationIds") {
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? []; const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) { if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter( updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
(id) => id !== surveyOrWorkspaceOrOrganizationId (id) => id !== surveyOrProjectOrOrganizationId
); );
} else { } else {
updatedNotificationSettings.unsubscribedOrganizationIds = [ updatedNotificationSettings.unsubscribedOrganizationIds = [
...unsubscribedOrganizationIds, ...unsubscribedOrganizationIds,
surveyOrWorkspaceOrOrganizationId, surveyOrProjectOrOrganizationId,
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType] = { updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType], ...updatedNotificationSettings[notificationType],
[surveyOrWorkspaceOrOrganizationId]: [surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId], !updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
}; };
} }
@@ -76,12 +76,12 @@ export const NotificationSwitch = ({
useEffect(() => { useEffect(() => {
if ( if (
autoDisableNotificationType && autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId && autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
isChecked isChecked
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) { if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
@@ -95,9 +95,7 @@ export const NotificationSwitch = ({
break; break;
case "unsubscribedOrganizationIds": case "unsubscribedOrganizationIds":
if ( if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
@@ -22,9 +22,9 @@ const setCompleteNotificationSettings = (
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
for (const workspace of membership.organization.workspaces) { for (const project of membership.organization.projects) {
// set default values for alerts // set default values for alerts
for (const environment of workspace.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] = newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id] (notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
@@ -47,17 +47,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
}, },
OR: [ OR: [
{ {
// Fetch all workspaces if user role is owner or manager // Fetch all projects if user role is owner or manager
role: { role: {
in: ["owner", "manager"], in: ["owner", "manager"],
}, },
}, },
{ {
// Filter workspaces based on team membership if user is not owner or manager // Filter projects based on team membership if user is not owner or manager
organization: { organization: {
workspaces: { projects: {
some: { some: {
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -79,12 +79,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: { select: {
id: true, id: true,
name: true, name: true,
workspaces: { projects: {
// Apply conditional filtering based on user's role // Apply conditional filtering based on user's role
where: { where: {
OR: [ OR: [
{ {
// Fetch all workspaces if user is owner or manager // Fetch all projects if user is owner or manager
organization: { organization: {
memberships: { memberships: {
some: { some: {
@@ -97,8 +97,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
}, },
}, },
{ {
// Only include workspaces accessible through teams if user is not owner or manager // Only include projects accessible through teams if user is not owner or manager
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -4,7 +4,7 @@ export interface Membership {
organization: { organization: {
id: string; id: string;
name: string; name: string;
workspaces: { projects: {
id: string; id: string;
name: string; name: string;
environments: { environments: {
@@ -40,13 +40,6 @@ export const OrganizationSettingsNavbar = ({
href: `/environments/${environmentId}/settings/teams`, href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"), current: pathname?.includes("/teams"),
}, },
{
id: "feedback-record-directories",
label: t("environments.settings.feedback_record_directories.nav_label"),
href: `/environments/${environmentId}/settings/feedback-record-directories`,
current: pathname?.includes("/feedback-record-directories"),
hidden: isMember,
},
{ {
id: "api-keys", id: "api-keys",
label: t("common.api_keys"), label: t("common.api_keys"),
@@ -14,7 +14,7 @@ interface SurveyWithSlug {
environment: { environment: {
id: string; id: string;
type: "production" | "development"; type: "production" | "development";
workspace: { project: {
id: string; id: string;
name: string; name: string;
}; };
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
}, },
{ {
label: t("environments.settings.domain.workspace"), label: t("environments.settings.domain.workspace"),
key: "workspace", key: "project",
}, },
{ {
label: t("environments.settings.domain.pretty_url"), label: t("environments.settings.domain.pretty_url"),
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
{survey.name} {survey.name}
</Link> </Link>
</TableCell> </TableCell>
<TableCell>{survey.environment.workspace.name}</TableCell> <TableCell>{survey.environment.project.name}</TableCell>
<TableCell> <TableCell>
<IdBadge id={survey.slug ?? ""} /> <IdBadge id={survey.slug ?? ""} />
</TableCell> </TableCell>
@@ -25,8 +25,8 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments", "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
}, },
{ {
key: "workspaces", key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_workspaces"), labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license", docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
}, },
{ {
@@ -1 +0,0 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -11,9 +11,9 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
const { children } = props; const { children } = props;
const t = await getTranslate(); const t = await getTranslate();
const [organization, workspace, session] = await Promise.all([ const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId), getOrganizationByEnvironmentId(params.environmentId),
getWorkspaceByEnvironmentId(params.environmentId), getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions), getServerSession(authOptions),
]); ]);
@@ -21,8 +21,8 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
throw new ResourceNotFoundError(t("common.organization"), null); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!workspace) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
@@ -8,7 +8,7 @@ import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getSurveySummary } from "./summary/lib/surveySummary"; import { getSurveySummary } from "./summary/lib/surveySummary";
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => { export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
@@ -36,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -70,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -98,9 +98,9 @@ export const getResponseCountAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -126,9 +126,9 @@ export const getDisplaysWithContactAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -8,7 +8,7 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { convertToCsv } from "@/lib/utils/file-conversion"; import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts"; import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -35,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -64,13 +64,13 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
workspaceId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -81,9 +81,9 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
workspaceId, projectId,
}, },
], ],
}); });
@@ -125,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -160,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -234,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { workspace } = useEnvironment(); const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
workspaceId: workspace.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
toast.success( toast.success(
@@ -212,7 +212,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured} isStorageConfigured={isStorageConfigured}
workspaceCustomScripts={workspace.customHeadScripts} projectCustomScripts={project.customHeadScripts}
/> />
)} )}
<SuccessMessage environment={environment} survey={survey} /> <SuccessMessage environment={environment} survey={survey} />
@@ -53,7 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isReadOnly: boolean; isReadOnly: boolean;
isStorageConfigured: boolean; isStorageConfigured: boolean;
workspaceCustomScripts?: string | null; projectCustomScripts?: string | null;
} }
export const ShareSurveyModal = ({ export const ShareSurveyModal = ({
@@ -68,7 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud, isFormbricksCloud,
isReadOnly, isReadOnly,
isStorageConfigured, isStorageConfigured,
workspaceCustomScripts, projectCustomScripts,
}: ShareSurveyModalProps) => { }: ShareSurveyModalProps) => {
const environmentId = survey.environmentId; const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default")); const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -193,7 +193,7 @@ export const ShareSurveyModal = ({
title: t("environments.surveys.share.custom_html.nav_title"), title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"), description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab, componentType: CustomHtmlTab,
componentProps: { workspaceCustomScripts, isReadOnly }, componentProps: { projectCustomScripts, isReadOnly },
}, },
]; ];
@@ -216,7 +216,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud, isFormbricksCloud,
email, email,
isStorageConfigured, isStorageConfigured,
workspaceCustomScripts, projectCustomScripts,
]); ]);
const getDefaultActiveId = useCallback(() => { const getDefaultActiveId = useCallback(() => {
@@ -88,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
export const AppTab = () => { export const AppTab = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { environment, workspace } = useEnvironment(); const { environment, project } = useEnvironment();
const { survey } = useSurvey(); const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]); const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
@@ -98,8 +98,8 @@ export const AppTab = () => {
if (survey.recontactDays !== null) { if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t); return formatRecontactDaysString(survey.recontactDays, t);
} }
if (workspace.recontactDays !== null) { if (project.recontactDays !== null) {
return formatRecontactDaysString(workspace.recontactDays, t); return formatRecontactDaysString(project.recontactDays, t);
} }
return t("environments.surveys.summary.in_app.display_criteria.time_based_always"); return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
}; };
@@ -22,7 +22,7 @@ import {
import { TabToggle } from "@/modules/ui/components/tab-toggle"; import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps { interface CustomHtmlTabProps {
workspaceCustomScripts: string | null | undefined; projectCustomScripts: string | null | undefined;
isReadOnly: boolean; isReadOnly: boolean;
} }
@@ -31,7 +31,7 @@ interface CustomHtmlFormData {
customHeadScriptsMode: TSurvey["customHeadScriptsMode"]; customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
} }
export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtmlTabProps) => { export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { survey } = useSurvey(); const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -101,18 +101,18 @@ export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtml
</div> </div>
{/* Workspace Scripts Preview */} {/* Workspace Scripts Preview */}
{workspaceCustomScripts && ( {projectCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600"> <pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{workspaceCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
</div> </div>
)} )}
{!workspaceCustomScripts && ( {!projectCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")} {t("environments.surveys.share.custom_html.no_workspace_scripts")}
@@ -1,8 +1,8 @@
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling"; import { getStyling } from "@/lib/utils/styling";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template"; import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
@@ -12,12 +12,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId); throw new ResourceNotFoundError(t("common.survey"), surveyId);
} }
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId); const project = await getProjectByEnvironmentId(survey.environmentId);
if (!workspace) { if (!project) {
throw new Error("Workspace not found"); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const styling = getStyling(workspace, survey); const styling = getStyling(project, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id; const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t); const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype = const doctype =
@@ -2,10 +2,10 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses"; import { TResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TLanguage } from "@formbricks/types/workspace";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey"; import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service"; import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -99,7 +99,7 @@ const mockBaseSurvey: TSurvey = {
createdBy: "user_123", createdBy: "user_123",
isSingleResponsePerEmailEnabled: false, isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false, isVerifyEmailEnabled: false,
workspaceOverwrites: null, projectOverwrites: null,
showLanguageSwitch: false, showLanguageSwitch: false,
isBackButtonHidden: false, isBackButtonHidden: false,
followUps: [], followUps: [],
@@ -9,7 +9,7 @@ import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { getOrganizationBilling } from "@/modules/survey/lib/survey";
@@ -32,9 +32,9 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -70,9 +70,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "read", minPermission: "read",
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
}, },
], ],
}); });
@@ -1,3 +1,3 @@
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading"; import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading; export default AppConnectionLoading;
@@ -1,3 +1,3 @@
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page"; import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage; export default AppConnectionPage;
@@ -1,3 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading"; import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading; export default GeneralSettingsLoading;
@@ -1,3 +1,3 @@
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page"; import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage; export default GeneralSettingsPage;
@@ -9,8 +9,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { import {
getOrganizationIdFromEnvironmentId, getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId, getOrganizationIdFromIntegrationId,
getWorkspaceIdFromEnvironmentId, getProjectIdFromEnvironmentId,
getWorkspaceIdFromIntegrationId, getProjectIdFromIntegrationId,
} from "@/lib/utils/helper"; } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -34,9 +34,9 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId), projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
}, },
], ],
}); });
@@ -66,8 +66,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId), projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -10,7 +10,7 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({ const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId, environmentId: ZId,
@@ -28,8 +28,8 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId), projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -62,8 +62,8 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId), projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -16,10 +16,10 @@ import ZapierLogo from "@/images/zapier-small.png";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Card } from "@/modules/ui/components/integration-card"; import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
const getStatusText = (count: number, t: TFunction, type: string) => { const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`; if (count === 1) return `1 ${type}`;
@@ -210,7 +210,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}> <PageHeader pageTitle={t("common.workspace_configuration")}>
<WorkspaceConfigNavigation environmentId={params.environmentId} activeId="integrations" /> <ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
</PageHeader> </PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3"> <div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => ( {integrationCards.map((card) => (
@@ -5,7 +5,7 @@ import { ZId } from "@formbricks/types/common";
import { getSlackChannels } from "@/lib/slack/service"; import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZGetSlackChannelsAction = z.object({ const ZGetSlackChannelsAction = z.object({
environmentId: ZId, environmentId: ZId,
@@ -23,8 +23,8 @@ export const getSlackChannelsAction = authenticatedActionClient
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
{ {
type: "workspaceTeam", type: "projectTeam",
workspaceId: await getWorkspaceIdFromEnvironmentId(parsedInput.environmentId), projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -1,3 +1,3 @@
import { LanguagesLoading } from "@/modules/workspaces/settings/languages/loading"; import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading; export default LanguagesLoading;
@@ -1,3 +1,3 @@
import { LanguagesPage } from "@/modules/workspaces/settings/languages/page"; import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage; export default LanguagesPage;
@@ -1,4 +1,4 @@
import { WorkspaceSettingsLayout, metadata } from "@/modules/workspaces/settings/layout"; import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata }; export { metadata };
export default WorkspaceSettingsLayout; export default ProjectSettingsLayout;
@@ -1,3 +1,3 @@
import { WorkspaceLookSettingsLoading } from "@/modules/workspaces/settings/look/loading"; import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default WorkspaceLookSettingsLoading; export default ProjectLookSettingsLoading;
@@ -1,3 +1,3 @@
import { WorkspaceLookSettingsPage } from "@/modules/workspaces/settings/look/page"; import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default WorkspaceLookSettingsPage; export default ProjectLookSettingsPage;
@@ -1,3 +1,3 @@
import { WorkspaceSettingsPage } from "@/modules/workspaces/settings/page"; import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default WorkspaceSettingsPage; export default ProjectSettingsPage;
@@ -1,3 +1,3 @@
import { TagsLoading } from "@/modules/workspaces/settings/tags/loading"; import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading; export default TagsLoading;
@@ -1,3 +1,3 @@
import { TagsPage } from "@/modules/workspaces/settings/tags/page"; import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage; export default TagsPage;
@@ -1,3 +1,3 @@
import { WorkspaceTeams } from "@/modules/ee/teams/workspace-teams/page"; import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default WorkspaceTeams; export default ProjectTeams;
@@ -6,7 +6,7 @@ import { hasOrganizationAccess } from "@/lib/auth";
import { getEnvironments } from "@/lib/environment/service"; import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (_: Request, context: { params: Promise<{ organizationId: string }> }) => { export const GET = async (_: Request, context: { params: Promise<{ organizationId: string }> }) => {
@@ -22,14 +22,14 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
// redirect to first workspace's production environment // redirect to first project's production environment
const workspaces = await getUserWorkspaces(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
if (workspaces.length === 0) { if (projects.length === 0) {
return redirect(`/organizations/${organizationId}/landing`); return redirect(`/organizations/${organizationId}/landing`);
} }
const firstWorkspace = workspaces[0]; const firstProject = projects[0];
const environments = await getEnvironments(firstWorkspace.id); const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production"); const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound(); if (!prodEnvironment) return notFound();
@@ -3,22 +3,22 @@ import { notFound, redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { hasOrganizationAccess } from "@/lib/auth"; import { hasOrganizationAccess } from "@/lib/auth";
import { getEnvironments } from "@/lib/environment/service"; import { getEnvironments } from "@/lib/environment/service";
import { getWorkspace } from "@/lib/workspace/service"; import { getProject } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (_: Request, context: { params: Promise<{ workspaceId: string }> }) => { export const GET = async (_: Request, context: { params: Promise<{ projectId: string }> }) => {
const params = await context?.params; const params = await context?.params;
const workspaceId = params.workspaceId; const projectId = params.projectId;
if (!workspaceId) return notFound(); if (!projectId) return notFound();
// check auth // check auth
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated"); if (!session) throw new AuthenticationError("Not authenticated");
const workspace = await getWorkspace(workspaceId); const project = await getProject(projectId);
if (!workspace) return notFound(); if (!project) return notFound();
const hasAccess = await hasOrganizationAccess(session.user.id, workspace.organizationId); const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
if (!hasAccess) throw new AuthorizationError("Unauthorized"); if (!hasAccess) throw new AuthorizationError("Unauthorized");
// redirect to workspace's production environment // redirect to project's production environment
const environments = await getEnvironments(workspace.id); const environments = await getEnvironments(project.id);
const prodEnvironment = environments.find((e) => e.type === "production"); const prodEnvironment = environments.find((e) => e.type === "production");
if (!prodEnvironment) return notFound(); if (!prodEnvironment) return notFound();
return redirect(`/environments/${prodEnvironment.id}/`); return redirect(`/environments/${prodEnvironment.id}/`);
@@ -155,7 +155,7 @@ export const mockSurvey: TSurvey = {
isSingleResponsePerEmailEnabled: false, isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false, isBackButtonHidden: false,
recaptcha: null, recaptcha: null,
workspaceOverwrites: null, projectOverwrites: null,
styling: null, styling: null,
surveyClosedMessage: null, surveyClosedMessage: null,
singleUse: { singleUse: {
@@ -20,7 +20,7 @@ vi.mock("@formbricks/database", () => ({
}, },
user: { count: vi.fn() }, user: { count: vi.fn() },
team: { count: vi.fn() }, team: { count: vi.fn() },
workspace: { count: vi.fn() }, project: { count: vi.fn() },
survey: { count: vi.fn() }, survey: { count: vi.fn() },
response: { response: {
count: vi.fn(), count: vi.fn(),
@@ -94,7 +94,7 @@ describe("sendTelemetryEvents", () => {
organizationCount: BigInt(1), organizationCount: BigInt(1),
userCount: BigInt(5), userCount: BigInt(5),
teamCount: BigInt(2), teamCount: BigInt(2),
workspaceCount: BigInt(3), projectCount: BigInt(3),
surveyCount: BigInt(10), surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4), inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6), completedSurveyCount: BigInt(6),
@@ -148,7 +148,7 @@ const sendTelemetry = async (lastSent: number) => {
organizationCount: bigint; organizationCount: bigint;
userCount: bigint; userCount: bigint;
teamCount: bigint; teamCount: bigint;
workspaceCount: bigint; projectCount: bigint;
surveyCount: bigint; surveyCount: bigint;
inProgressSurveyCount: bigint; inProgressSurveyCount: bigint;
completedSurveyCount: bigint; completedSurveyCount: bigint;
@@ -165,7 +165,7 @@ const sendTelemetry = async (lastSent: number) => {
(SELECT COUNT(*) FROM "Organization") as "organizationCount", (SELECT COUNT(*) FROM "Organization") as "organizationCount",
(SELECT COUNT(*) FROM "User") as "userCount", (SELECT COUNT(*) FROM "User") as "userCount",
(SELECT COUNT(*) FROM "Team") as "teamCount", (SELECT COUNT(*) FROM "Team") as "teamCount",
(SELECT COUNT(*) FROM "Workspace") as "workspaceCount", (SELECT COUNT(*) FROM "Project") as "projectCount",
(SELECT COUNT(*) FROM "Survey") as "surveyCount", (SELECT COUNT(*) FROM "Survey") as "surveyCount",
(SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount", (SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount",
(SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount", (SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount",
@@ -186,7 +186,7 @@ const sendTelemetry = async (lastSent: number) => {
const organizationCount = Number(counts.organizationCount); const organizationCount = Number(counts.organizationCount);
const userCount = Number(counts.userCount); const userCount = Number(counts.userCount);
const teamCount = Number(counts.teamCount); const teamCount = Number(counts.teamCount);
const workspaceCount = Number(counts.workspaceCount); const projectCount = Number(counts.projectCount);
const surveyCount = Number(counts.surveyCount); const surveyCount = Number(counts.surveyCount);
const inProgressSurveyCount = Number(counts.inProgressSurveyCount); const inProgressSurveyCount = Number(counts.inProgressSurveyCount);
const completedSurveyCount = Number(counts.completedSurveyCount); const completedSurveyCount = Number(counts.completedSurveyCount);
@@ -222,7 +222,7 @@ const sendTelemetry = async (lastSent: number) => {
organizationCount, organizationCount,
userCount, userCount,
teamCount, teamCount,
workspaceCount, projectCount,
surveyCount, surveyCount,
inProgressSurveyCount, inProgressSurveyCount,
completedSurveyCount, completedSurveyCount,
@@ -168,7 +168,7 @@ export const POST = async (request: Request) => {
memberships: { memberships: {
some: { some: {
organization: { organization: {
workspaces: { projects: {
some: { some: {
environments: { environments: {
some: { id: environmentId }, some: { id: environmentId },
@@ -192,9 +192,9 @@ export const POST = async (request: Request) => {
teamUsers: { teamUsers: {
some: { some: {
team: { team: {
workspaceTeams: { projectTeams: {
some: { some: {
workspace: { project: {
environments: { environments: {
some: { some: {
id: environmentId, id: environmentId,
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+67 -68
View File
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions"; import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => { const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined; const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions, ...baseAuthOptions,
callbacks: { callbacks: {
...baseAuthOptions.callbacks, ...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) { async session(params: any) {
let result: any = params.session; let result: any = params.session;
let error: any = undefined; let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) { }) {
let result: boolean | string = true; let result: boolean | string = true;
let error: any = undefined; let error: any = undefined;
let authMethod = "unknown"; const authMethod = getAuthMethod(account);
try { try {
if (baseAuthOptions.callbacks?.signIn) { if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials, credentials,
}); });
} }
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) { } catch (err) {
error = err; error = err;
result = false; result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
} }
} }
const status: TAuditStatus = result === false ? "failure" : "success"; if (result === false) {
const auditLog = { queueAuditEventBackground({
action: "signedIn" as const, action: "signedIn",
targetType: "user" as const, targetType: "user",
userId: user?.id ?? UNKNOWN_DATA, userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA, targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA, organizationId: UNKNOWN_DATA,
status, status: "failure",
userType: "user" as const, userType: "user",
newObject: { newObject: {
...user, ...user,
authMethod, authMethod,
provider: account?.provider, provider: account?.provider,
...(error ? { errorMessage: error.message } : {}), ...(error instanceof Error ? { errorMessage: error.message } : {}),
}, },
...(status === "failure" ? { eventId } : {}), eventId,
}; });
}
queueAuditEventBackground(auditLog);
if (error) throw error; if (error) throw error;
return result; return result;
}, },
}, },
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
}; };
return NextAuth(authOptions)(req, ctx); return NextAuth(authOptions)(req, ctx);
+12 -12
View File
@@ -29,9 +29,9 @@ describe("getApiKeyWithPermissions", () => {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
type: "development" as const, type: "development" as const,
workspaceId: "workspace-1", projectId: "project-1",
appSetupCompleted: true, appSetupCompleted: true,
workspace: { id: "workspace-1", name: "Workspace 1" }, project: { id: "project-1", name: "Project 1" },
}, },
}, },
], ],
@@ -60,22 +60,22 @@ describe("hasPermission", () => {
environmentId: "env-1", environmentId: "env-1",
permission: "manage", permission: "manage",
environmentType: "development", environmentType: "development",
workspaceId: "workspace-1", projectId: "project-1",
workspaceName: "Workspace 1", projectName: "Project 1",
}, },
{ {
environmentId: "env-2", environmentId: "env-2",
permission: "write", permission: "write",
environmentType: "production", environmentType: "production",
workspaceId: "workspace-2", projectId: "project-2",
workspaceName: "Workspace 2", projectName: "Project 2",
}, },
{ {
environmentId: "env-3", environmentId: "env-3",
permission: "read", permission: "read",
environmentType: "development", environmentType: "development",
workspaceId: "workspace-3", projectId: "project-3",
workspaceName: "Workspace 3", projectName: "Project 3",
}, },
]; ];
@@ -125,9 +125,9 @@ describe("authenticateRequest", () => {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
type: "development" as const, type: "development" as const,
workspaceId: "workspace-1", projectId: "project-1",
appSetupCompleted: true, appSetupCompleted: true,
workspace: { id: "workspace-1", name: "Workspace 1" }, project: { id: "project-1", name: "Project 1" },
}, },
}, },
], ],
@@ -143,8 +143,8 @@ describe("authenticateRequest", () => {
environmentId: "env-1", environmentId: "env-1",
permission: "manage", permission: "manage",
environmentType: "development", environmentType: "development",
workspaceId: "workspace-1", projectId: "project-1",
workspaceName: "Workspace 1", projectName: "Project 1",
}, },
], ],
apiKeyId: "api-key-id", apiKeyId: "api-key-id",
+2 -2
View File
@@ -22,8 +22,8 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
environmentId: env.environmentId, environmentId: env.environmentId,
environmentType: env.environment.type, environmentType: env.environment.type,
permission: env.permission, permission: env.permission,
workspaceId: env.environment.workspaceId, projectId: env.environment.projectId,
workspaceName: env.environment.workspace.name, projectName: env.environment.project.name,
})), })),
apiKeyId: apiKeyData.id, apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId, organizationId: apiKeyData.organizationId,
@@ -30,8 +30,8 @@ const mockEnvironmentData = {
id: environmentId, id: environmentId,
type: "production", type: "production",
appSetupCompleted: true, appSetupCompleted: true,
workspace: { project: {
id: "workspace-123", id: "project-123",
recontactDays: 30, recontactDays: 30,
clickOutsideClose: true, clickOutsideClose: true,
overlay: "none", overlay: "none",
@@ -73,7 +73,7 @@ const mockEnvironmentData = {
triggers: [], triggers: [],
displayPercentage: null, displayPercentage: null,
delay: 0, delay: 0,
workspaceOverwrites: null, projectOverwrites: null,
}, },
], ],
}; };
@@ -97,8 +97,8 @@ describe("getEnvironmentStateData", () => {
id: environmentId, id: environmentId,
type: "production", type: "production",
appSetupCompleted: true, appSetupCompleted: true,
workspace: { project: {
id: "workspace-123", id: "project-123",
recontactDays: 30, recontactDays: 30,
clickOutsideClose: true, clickOutsideClose: true,
overlay: "none", overlay: "none",
@@ -117,7 +117,7 @@ describe("getEnvironmentStateData", () => {
id: true, id: true,
type: true, type: true,
appSetupCompleted: true, appSetupCompleted: true,
workspace: expect.any(Object), project: expect.any(Object),
actionClasses: expect.any(Object), actionClasses: expect.any(Object),
surveys: expect.any(Object), surveys: expect.any(Object),
}), }),
@@ -131,10 +131,10 @@ describe("getEnvironmentStateData", () => {
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment"); await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
}); });
test("should throw ResourceNotFoundError when workspace is not found", async () => { test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({ vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData, ...mockEnvironmentData,
workspace: null, project: null,
} as never); } as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
@@ -201,9 +201,9 @@ describe("getEnvironmentStateData", () => {
expect(result.surveys).toHaveLength(2); expect(result.surveys).toHaveLength(2);
}); });
test("should correctly map workspace properties to environment.workspace", async () => { test("should correctly map project properties to environment.project", async () => {
const customWorkspace = { const customProject = {
...mockEnvironmentData.workspace, ...mockEnvironmentData.project,
recontactDays: 14, recontactDays: 14,
clickOutsideClose: false, clickOutsideClose: false,
overlay: "dark", overlay: "dark",
@@ -214,13 +214,13 @@ describe("getEnvironmentStateData", () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({ vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData, ...mockEnvironmentData,
workspace: customWorkspace, project: customProject,
} as never); } as never);
const result = await getEnvironmentStateData(environmentId); const result = await getEnvironmentStateData(environmentId);
expect(result.environment.workspace).toEqual({ expect(result.environment.project).toEqual({
id: "workspace-123", id: "project-123",
recontactDays: 14, recontactDays: 14,
clickOutsideClose: false, clickOutsideClose: false,
overlay: "dark", overlay: "dark",
@@ -6,8 +6,8 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { import {
TJsEnvironmentStateActionClass, TJsEnvironmentStateActionClass,
TJsEnvironmentStateProject,
TJsEnvironmentStateSurvey, TJsEnvironmentStateSurvey,
TJsEnvironmentStateWorkspace,
} from "@formbricks/types/js"; } from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
@@ -23,7 +23,7 @@ export interface EnvironmentStateData {
id: string; id: string;
type: string; type: string;
appSetupCompleted: boolean; appSetupCompleted: boolean;
workspace: TJsEnvironmentStateWorkspace; project: TJsEnvironmentStateProject;
}; };
surveys: TJsEnvironmentStateSurvey[]; surveys: TJsEnvironmentStateSurvey[];
actionClasses: TJsEnvironmentStateActionClass[]; actionClasses: TJsEnvironmentStateActionClass[];
@@ -45,8 +45,8 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true, id: true,
type: true, type: true,
appSetupCompleted: true, appSetupCompleted: true,
// Workspace data (optimized select) // Project data (optimized select)
workspace: { project: {
select: { select: {
id: true, id: true,
recontactDays: true, recontactDays: true,
@@ -97,7 +97,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
alias: true, alias: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
workspaceId: true, projectId: true,
}, },
}, },
}, },
@@ -132,7 +132,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
}, },
displayPercentage: true, displayPercentage: true,
delay: true, delay: true,
workspaceOverwrites: true, projectOverwrites: true,
}, },
}, },
}, },
@@ -142,8 +142,8 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("environment", environmentId); throw new ResourceNotFoundError("environment", environmentId);
} }
if (!environmentData.workspace) { if (!environmentData.project) {
throw new ResourceNotFoundError("workspace", null); throw new ResourceNotFoundError("project", null);
} }
// Transform surveys using existing utility // Transform surveys using existing utility
@@ -156,14 +156,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.id, id: environmentData.id,
type: environmentData.type, type: environmentData.type,
appSetupCompleted: environmentData.appSetupCompleted, appSetupCompleted: environmentData.appSetupCompleted,
workspace: { project: {
id: environmentData.workspace.id, id: environmentData.project.id,
recontactDays: environmentData.workspace.recontactDays, recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.workspace.clickOutsideClose, clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.workspace.overlay, overlay: environmentData.project.overlay,
placement: environmentData.workspace.placement, placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.workspace.inAppSurveyBranding, inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.workspace.styling), styling: resolveStorageUrlsInObject(environmentData.project.styling),
}, },
}, },
surveys: resolveStorageUrlsInObject(transformedSurveys), surveys: resolveStorageUrlsInObject(transformedSurveys),
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateWorkspace } from "@formbricks/types/js"; import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
@@ -49,8 +49,8 @@ vi.mock("@formbricks/cache", () => ({
const environmentId = "test-environment-id"; const environmentId = "test-environment-id";
const mockWorkspace: TJsEnvironmentStateWorkspace = { const mockProject: TJsEnvironmentStateProject = {
id: "test-workspace-id", id: "test-project-id",
recontactDays: 30, recontactDays: 30,
inAppSurveyBranding: true, inAppSurveyBranding: true,
placement: "bottomRight", placement: "bottomRight",
@@ -69,7 +69,7 @@ const mockOrganization: TOrganization = {
billing: { billing: {
stripeCustomerId: null, stripeCustomerId: null,
limits: { limits: {
workspaces: 1, projects: 1,
monthly: { monthly: {
responses: 100, responses: 100,
}, },
@@ -94,7 +94,7 @@ const mockSurveys: TSurvey[] = [
isBackButtonHidden: false, isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false, isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false, isVerifyEmailEnabled: false,
workspaceOverwrites: null, projectOverwrites: null,
showLanguageSwitch: false, showLanguageSwitch: false,
questions: [], questions: [],
displayOption: "displayOnce", displayOption: "displayOnce",
@@ -137,7 +137,11 @@ const mockEnvironmentStateData: EnvironmentStateData = {
id: environmentId, id: environmentId,
type: "production", type: "production",
appSetupCompleted: true, appSetupCompleted: true,
workspace: mockWorkspace, project: mockProject,
},
organization: {
id: mockOrganization.id,
billing: mockOrganization.billing,
}, },
surveys: mockSurveys, surveys: mockSurveys,
actionClasses: mockActionClasses, actionClasses: mockActionClasses,
@@ -161,19 +165,11 @@ describe("getEnvironmentState", () => {
test("should return the correct environment state", async () => { test("should return the correct environment state", async () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
// Backwards compat: response includes `project` alongside `workspace`, const expectedData: TJsEnvironmentState["data"] = {
// and each survey includes `projectOverwrites` alongside `workspaceOverwrites`
const surveysWithLegacy = mockSurveys.map((s) => ({
...s,
projectOverwrites: (s as Record<string, unknown>).workspaceOverwrites ?? null,
}));
const expectedData = {
recaptchaSiteKey: "mock_recaptcha_site_key", recaptchaSiteKey: "mock_recaptcha_site_key",
surveys: surveysWithLegacy, surveys: mockSurveys,
actionClasses: mockActionClasses, actionClasses: mockActionClasses,
workspace: mockWorkspace, project: mockProject,
project: mockWorkspace,
}; };
expect(result.data).toEqual(expectedData); expect(result.data).toEqual(expectedData);
@@ -193,8 +189,8 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
}); });
test("should throw ResourceNotFoundError if workspace not found", async () => { test("should throw ResourceNotFoundError if project not found", async () => {
vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("workspace", null)); vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("project", null));
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
}); });
@@ -280,12 +276,7 @@ describe("getEnvironmentState", () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
// Backwards compat: each survey includes `projectOverwrites` expect(result.data.surveys).toEqual(mixedSurveys);
const expectedSurveys = mixedSurveys.map((s) => ({
...s,
projectOverwrites: (s as Record<string, unknown>).workspaceOverwrites ?? null,
}));
expect(result.data.surveys).toEqual(expectedSurveys);
}); });
test("should handle empty surveys array", async () => { test("should handle empty surveys array", async () => {
@@ -2,10 +2,6 @@ import "server-only";
import { createCacheKey } from "@formbricks/cache"; import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState } from "@formbricks/types/js";
import {
addLegacyProjectOverwritesToList,
addLegacyProjectToEnvironmentState,
} from "@/app/lib/api/api-backwards-compat";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getEnvironmentStateData } from "./data"; import { getEnvironmentStateData } from "./data";
@@ -17,7 +13,7 @@ import { getEnvironmentStateData } from "./data";
* *
* @param environmentId - The environment ID to fetch state for * @param environmentId - The environment ID to fetch state for
* @returns The environment state * @returns The environment state
* @throws ResourceNotFoundError if environment, organization, or workspace not found * @throws ResourceNotFoundError if environment, organization, or project not found
*/ */
export const getEnvironmentState = async ( export const getEnvironmentState = async (
environmentId: string environmentId: string
@@ -37,14 +33,12 @@ export const getEnvironmentState = async (
} }
// Build the response data // Build the response data
// Backwards compat: include `project` alongside `workspace`, and const data: TJsEnvironmentState["data"] = {
// `projectOverwrites` alongside `workspaceOverwrites` in each survey surveys,
const data = addLegacyProjectToEnvironmentState({
surveys: addLegacyProjectOverwritesToList(surveys),
actionClasses, actionClasses,
workspace: environment.workspace, project: environment.project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
} as TJsEnvironmentState["data"]); };
return { data }; return { data };
}, },
@@ -45,7 +45,6 @@ export const responseSelection = {
updatedAt: true, updatedAt: true,
name: true, name: true,
environmentId: true, environmentId: true,
workspaceId: true,
}, },
}, },
}, },
@@ -19,7 +19,6 @@ const selectActionClass = {
key: true, key: true,
noCodeConfig: true, noCodeConfig: true,
environmentId: true, environmentId: true,
workspaceId: true,
} satisfies Prisma.ActionClassSelect; } satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => { export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {
+7 -10
View File
@@ -21,9 +21,9 @@ const apiKeySelect = {
type: true, type: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
workspaceId: true, projectId: true,
appSetupCompleted: true, appSetupCompleted: true,
workspace: { project: {
select: { select: {
id: true, id: true,
name: true, name: true,
@@ -49,9 +49,9 @@ type ApiKeyData = {
type: string; type: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
workspaceId: string; projectId: string;
appSetupCompleted: boolean; appSetupCompleted: boolean;
workspace: { project: {
id: string; id: string;
name: string; name: string;
}; };
@@ -124,13 +124,10 @@ const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
createdAt: env.createdAt, createdAt: env.createdAt,
updatedAt: env.updatedAt, updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted, appSetupCompleted: env.appSetupCompleted,
workspace: { project: {
id: env.workspaceId, id: env.projectId,
name: env.workspace.name, name: env.project.name,
}, },
// Backwards compat: old consumers expect project fields
projectId: env.workspaceId,
projectName: env.workspace.name,
}); });
}; };
@@ -27,7 +27,7 @@ const mockOrganization = {
updatedAt: new Date(), updatedAt: new Date(),
billing: { billing: {
stripeCustomerId: null, stripeCustomerId: null,
limits: { workspaces: 3, monthly: { responses: null } }, limits: { projects: 3, monthly: { responses: null } },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
} as TOrganizationBilling, // Default no limit } as TOrganizationBilling, // Default no limit
} as unknown as Organization; } as unknown as Organization;
@@ -50,7 +50,6 @@ export const responseSelection = {
updatedAt: true, updatedAt: true,
name: true, name: true,
environmentId: true, environmentId: true,
workspaceId: true,
}, },
}, },
}, },
@@ -52,8 +52,8 @@ describe("checkAuth", () => {
environmentId: "env-123", environmentId: "env-123",
permission: "read", permission: "read",
environmentType: "development", environmentType: "development",
workspaceId: "workspace-1", projectId: "project-1",
workspaceName: "Workspace 1", projectName: "Project 1",
}, },
], ],
apiKeyId: "hashed-key", apiKeyId: "hashed-key",
@@ -84,8 +84,8 @@ describe("checkAuth", () => {
environmentId: "env-123", environmentId: "env-123",
permission: "write", permission: "write",
environmentType: "development", environmentType: "development",
workspaceId: "workspace-1", projectId: "project-1",
workspaceName: "Workspace 1", projectName: "Project 1",
}, },
], ],
apiKeyId: "hashed-key", apiKeyId: "hashed-key",
@@ -4,10 +4,6 @@ import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth"; import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys"; import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { import {
transformBlocksToQuestions, transformBlocksToQuestions,
@@ -61,21 +57,17 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) { if (shouldTransformToQuestions) {
return { return {
response: responses.successResponse( response: responses.successResponse(
addLegacyProjectOverwrites( resolveStorageUrlsInObject({
resolveStorageUrlsInObject({ ...result.survey,
...result.survey, questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings), blocks: [],
blocks: [], })
})
)
), ),
}; };
} }
return { return {
response: responses.successResponse( response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
addLegacyProjectOverwrites(resolveStorageUrlsInObject(result.survey))
),
}; };
} catch (error) { } catch (error) {
return { return {
@@ -167,9 +159,6 @@ export const PUT = withV1ApiWrapper({
}; };
} }
// Backwards compat: accept projectOverwrites as alias for workspaceOverwrites
surveyUpdate = normaliseProjectOverwritesToWorkspace(surveyUpdate);
const validateResult = validateSurveyInput({ ...surveyUpdate, updateOnly: true }); const validateResult = validateSurveyInput({ ...surveyUpdate, updateOnly: true });
if (!validateResult.ok) { if (!validateResult.ok) {
return { return {
@@ -222,16 +211,12 @@ export const PUT = withV1ApiWrapper({
}; };
return { return {
response: responses.successResponse( response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
addLegacyProjectOverwrites(resolveStorageUrlsInObject(surveyWithQuestions))
),
}; };
} }
return { return {
response: responses.successResponse( response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
addLegacyProjectOverwrites(resolveStorageUrlsInObject(updatedSurvey))
),
}; };
} catch (error) { } catch (error) {
return { return {
@@ -42,7 +42,7 @@ const mockOrganization: TOrganization = {
billing: { billing: {
stripeCustomerId: null, stripeCustomerId: null,
limits: { limits: {
workspaces: 3, projects: 3,
monthly: { monthly: {
responses: 1500, responses: 1500,
}, },
@@ -79,7 +79,7 @@ const mockLanguage: TSurveyCreateInputWithEnvironmentId["languages"][number] = {
code: "en", code: "en",
alias: "English", alias: "English",
createdAt: new Date(), createdAt: new Date(),
workspaceId: "mockWorkspaceId", projectId: "mockProjectId",
updatedAt: new Date(), updatedAt: new Date(),
}, },
default: true, default: true,
@@ -2,11 +2,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import {
addLegacyProjectOverwrites,
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { import {
transformBlocksToQuestions, transformBlocksToQuestions,
@@ -58,9 +53,7 @@ export const GET = withV1ApiWrapper({
}); });
return { return {
response: responses.successResponse( response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
addLegacyProjectOverwritesToList(resolveStorageUrlsInObject(surveysWithQuestions))
),
}; };
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -90,9 +83,6 @@ export const POST = withV1ApiWrapper({
}; };
} }
// Backwards compat: accept projectOverwrites as alias for workspaceOverwrites
surveyInput = normaliseProjectOverwritesToWorkspace(surveyInput);
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput); const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) { if (!inputValidation.success) {
@@ -157,12 +147,12 @@ export const POST = withV1ApiWrapper({
}; };
return { return {
response: responses.successResponse(addLegacyProjectOverwrites(surveyWithQuestions)), response: responses.successResponse(surveyWithQuestions),
}; };
} }
return { return {
response: responses.successResponse(addLegacyProjectOverwrites(survey)), response: responses.successResponse(survey),
}; };
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -22,7 +22,7 @@ describe("getOrganizationBillingByEnvironmentId", () => {
const mockBillingData: TOrganizationBilling = { const mockBillingData: TOrganizationBilling = {
limits: { limits: {
monthly: { responses: 0 }, monthly: { responses: 0 },
workspaces: 3, projects: 3,
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id", stripeCustomerId: "mock-stripe-customer-id",
@@ -34,7 +34,7 @@ describe("getOrganizationBillingByEnvironmentId", () => {
expect(result).toEqual(mockBillingData); expect(result).toEqual(mockBillingData);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({ expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: { where: {
workspaces: { projects: {
some: { some: {
environments: { environments: {
some: { some: {
@@ -8,7 +8,7 @@ export const getOrganizationBillingByEnvironmentId = reactCache(
try { try {
const organization = await prisma.organization.findFirst({ const organization = await prisma.organization.findFirst({
where: { where: {
workspaces: { projects: {
some: { some: {
environments: { environments: {
some: { some: {
@@ -86,7 +86,7 @@ const mockSurvey: TSurvey = {
isBackButtonHidden: false, isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false, isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false, isVerifyEmailEnabled: false,
workspaceOverwrites: null, projectOverwrites: null,
showLanguageSwitch: false, showLanguageSwitch: false,
blocks: [], blocks: [],
isCaptureIpEnabled: false, isCaptureIpEnabled: false,
@@ -106,7 +106,7 @@ const mockResponseInput: TResponseInputV2 = {
const mockBillingData: TOrganizationBilling = { const mockBillingData: TOrganizationBilling = {
limits: { limits: {
monthly: { responses: 0 }, monthly: { responses: 0 },
workspaces: 3, projects: 3,
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id", stripeCustomerId: "mock-stripe-customer-id",
@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };
@@ -1,8 +0,0 @@
import {
DELETE,
GET,
POST,
PUT,
} from "@/modules/api/v2/organizations/[organizationId]/workspace-teams/route";
export { GET, POST, PUT, DELETE };
+19 -19
View File
@@ -2,7 +2,7 @@ import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services"; import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth"; import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
@@ -16,7 +16,7 @@ vi.mock("@formbricks/logger", () => ({
})); }));
vi.mock("@/lib/utils/helper", () => ({ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(), getOrganizationIdFromProjectId: vi.fn(),
})); }));
vi.mock("@/lib/utils/services", () => ({ vi.mock("@/lib/utils/services", () => ({
@@ -79,9 +79,9 @@ describe("requireSessionWorkspaceAccess", () => {
test("returns 403 when user has no access to workspace", async () => { test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({ vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc", id: "env_abc",
workspaceId: "proj_abc", projectId: "proj_abc",
} as any); } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized")); vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess( const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any, { user: { id: "user_1" }, expires: "" } as any,
@@ -99,7 +99,7 @@ describe("requireSessionWorkspaceAccess", () => {
organizationId: "org_1", organizationId: "org_1",
access: [ access: [
{ type: "organization", roles: ["owner", "manager"] }, { type: "organization", roles: ["owner", "manager"] },
{ type: "workspaceTeam", workspaceId: "proj_abc", minPermission: "read" }, { type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
], ],
}); });
}); });
@@ -107,9 +107,9 @@ describe("requireSessionWorkspaceAccess", () => {
test("returns workspace context when session is valid and user has access", async () => { test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({ vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc", id: "env_abc",
workspaceId: "proj_abc", projectId: "proj_abc",
} as any); } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess( const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any, { user: { id: "user_1" }, expires: "" } as any,
@@ -120,7 +120,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(result).not.toBeInstanceOf(Response); expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({ expect(result).toEqual({
environmentId: "env_abc", environmentId: "env_abc",
workspaceId: "proj_abc", projectId: "proj_abc",
organizationId: "org_1", organizationId: "org_1",
}); });
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
@@ -128,7 +128,7 @@ describe("requireSessionWorkspaceAccess", () => {
organizationId: "org_1", organizationId: "org_1",
access: [ access: [
{ type: "organization", roles: ["owner", "manager"] }, { type: "organization", roles: ["owner", "manager"] },
{ type: "workspaceTeam", workspaceId: "proj_abc", minPermission: "readWrite" }, { type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
], ],
}); });
}); });
@@ -145,8 +145,8 @@ function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPer
return { return {
environmentId, environmentId,
environmentType: EnvironmentType.development, environmentType: EnvironmentType.development,
workspaceId: "proj_k", projectId: "proj_k",
workspaceName: "K", projectName: "K",
permission, permission,
}; };
} }
@@ -155,9 +155,9 @@ describe("requireV3WorkspaceAccess", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({ vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k", id: "env_k",
workspaceId: "proj_k", projectId: "proj_k",
} as any); } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k"); vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
}); });
test("401 when authentication is null", async () => { test("401 when authentication is null", async () => {
@@ -168,9 +168,9 @@ describe("requireV3WorkspaceAccess", () => {
test("delegates to session flow when user is present", async () => { test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({ vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s", id: "env_s",
workspaceId: "proj_s", projectId: "proj_s",
} as any); } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s"); vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess( const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any, { user: { id: "user_1" }, expires: "" } as any,
@@ -180,7 +180,7 @@ describe("requireV3WorkspaceAccess", () => {
); );
expect(r).toEqual({ expect(r).toEqual({
environmentId: "env_s", environmentId: "env_s",
workspaceId: "proj_s", projectId: "proj_s",
organizationId: "org_s", organizationId: "org_s",
}); });
}); });
@@ -193,7 +193,7 @@ describe("requireV3WorkspaceAccess", () => {
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId); const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({ expect(r).toEqual({
environmentId: "ws_a", environmentId: "ws_a",
workspaceId: "proj_k", projectId: "proj_k",
organizationId: "org_k", organizationId: "org_k",
}); });
expect(getEnvironment).toHaveBeenCalledWith("ws_a"); expect(getEnvironment).toHaveBeenCalledWith("ws_a");
@@ -207,7 +207,7 @@ describe("requireV3WorkspaceAccess", () => {
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId); const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({ expect(r).toEqual({
environmentId: "ws_b", environmentId: "ws_b",
workspaceId: "proj_k", projectId: "proj_k",
organizationId: "org_k", organizationId: "org_k",
}); });
}); });
@@ -252,7 +252,7 @@ describe("requireV3WorkspaceAccess", () => {
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId); const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({ expect(r).toEqual({
environmentId: "ws_m", environmentId: "ws_m",
workspaceId: "proj_k", projectId: "proj_k",
organizationId: "org_k", organizationId: "org_k",
}); });
}); });
+5 -5
View File
@@ -6,7 +6,7 @@ import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth"; import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response"; import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types"; import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context"; import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
@@ -30,7 +30,7 @@ function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTe
/** /**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer. * Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers * Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, workspaceId, organizationId) without resolving again. * use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence. * We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/ */
export async function requireSessionWorkspaceAccess( export async function requireSessionWorkspaceAccess(
@@ -52,16 +52,16 @@ export async function requireSessionWorkspaceAccess(
const log = logger.withContext({ requestId, workspaceId }); const log = logger.withContext({ requestId, workspaceId });
try { try {
// Resolve workspaceId → environmentId, workspaceId, organizationId (single place to change when Workspace exists). // Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId); const context = await resolveV3WorkspaceContext(workspaceId);
// Org + workspace-team access; we use internal IDs from context. // Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId, userId,
organizationId: context.organizationId, organizationId: context.organizationId,
access: [ access: [
{ type: "organization", roles: ["owner", "manager"] }, { type: "organization", roles: ["owner", "manager"] },
{ type: "workspaceTeam", workspaceId: context.workspaceId, minPermission }, { type: "projectTeam", projectId: context.projectId, minPermission },
], ],
}); });
@@ -1,11 +1,11 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services"; import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context"; import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(), getOrganizationIdFromProjectId: vi.fn(),
})); }));
vi.mock("@/lib/utils/services", () => ({ vi.mock("@/lib/utils/services", () => ({
@@ -13,26 +13,26 @@ vi.mock("@/lib/utils/services", () => ({
})); }));
describe("resolveV3WorkspaceContext", () => { describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, workspaceId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => { test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({ vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc", id: "env_abc",
workspaceId: "proj_xyz", projectId: "proj_xyz",
} as any); } as any);
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123"); vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc"); const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({ expect(result).toEqual({
environmentId: "env_abc", environmentId: "env_abc",
workspaceId: "proj_xyz", projectId: "proj_xyz",
organizationId: "org_123", organizationId: "org_123",
}); });
expect(getEnvironment).toHaveBeenCalledWith("env_abc"); expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("proj_xyz"); expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
}); });
test("throws when workspace (environment) does not exist", async () => { test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null); vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError); await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent"); expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled(); expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
}); });
}); });
+7 -7
View File
@@ -9,7 +9,7 @@
* (and derive environmentId or equivalent from it). Change only this file. * (and derive environmentId or equivalent from it). Change only this file.
*/ */
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services"; import { getEnvironment } from "@/lib/utils/services";
/** /**
@@ -19,14 +19,14 @@ import { getEnvironment } from "@/lib/utils/services";
export type V3WorkspaceContext = { export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */ /** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string; environmentId: string;
/** Workspace ID used for workspaceTeam auth. */ /** Project ID used for projectTeam auth. */
workspaceId: string; projectId: string;
/** Organization ID used for org-level auth. */ /** Organization ID used for org-level auth. */
organizationId: string; organizationId: string;
}; };
/** /**
* Resolves a V3 API workspaceId to internal environmentId, workspaceId, and organizationId. * Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment). * Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
* *
* @throws ResourceNotFoundError if the workspace (environment) does not exist. * @throws ResourceNotFoundError if the workspace (environment) does not exist.
@@ -38,13 +38,13 @@ export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3
throw new ResourceNotFoundError("environment", workspaceId); throw new ResourceNotFoundError("environment", workspaceId);
} }
// Derive org for auth; workspace comes from the environment. // Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromWorkspaceId(environment.workspaceId); const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId. // We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return { return {
environmentId: workspaceId, environmentId: workspaceId,
workspaceId: environment.workspaceId, projectId: environment.projectId,
organizationId, organizationId,
}; };
} }
+6 -6
View File
@@ -81,8 +81,8 @@ const apiKeyAuth = {
{ {
environmentId: validWorkspaceId, environmentId: validWorkspaceId,
environmentType: EnvironmentType.development, environmentType: EnvironmentType.development,
workspaceId: "proj_1", projectId: "proj_1",
workspaceName: "P", projectName: "P",
permission: ApiKeyPermission.read, permission: ApiKeyPermission.read,
}, },
], ],
@@ -112,13 +112,13 @@ describe("GET /api/v3/surveys", () => {
} }
return { return {
environmentId: workspaceId, environmentId: workspaceId,
workspaceId: p.workspaceId, projectId: p.projectId,
organizationId: auth.organizationId, organizationId: auth.organizationId,
}; };
} }
return { return {
environmentId: resolvedEnvironmentId, environmentId: resolvedEnvironmentId,
workspaceId: "proj_1", projectId: "proj_1",
organizationId: "org_1", organizationId: "org_1",
}; };
}); });
@@ -194,8 +194,8 @@ describe("GET /api/v3/surveys", () => {
{ {
environmentId: "claa1111111111111111111111", environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development, environmentType: EnvironmentType.development,
workspaceId: "proj_x", projectId: "proj_x",
workspaceName: "X", projectName: "X",
permission: ApiKeyPermission.read, permission: ApiKeyPermission.read,
}, },
], ],
@@ -1,65 +0,0 @@
/**
* Backwards compatibility layer for the project workspace rename across APIs.
*
* Provides utilities to normalise legacy `project*` field names to their `workspace*`
* equivalents in request bodies and enrich responses with legacy fields so existing
* integrations keep working.
*/
// ---------------------------------------------------------------------------
// Input transformation: accept `projectOverwrites` as an alias for `workspaceOverwrites`
// ---------------------------------------------------------------------------
/**
* Normalise a survey request body so that `projectOverwrites` is mapped to `workspaceOverwrites`.
* If both are provided, `workspaceOverwrites` takes precedence.
*/
export const normaliseProjectOverwritesToWorkspace = <T extends Record<string, unknown>>(input: T): T => {
if ("projectOverwrites" in input && !("workspaceOverwrites" in input)) {
const { projectOverwrites, ...rest } = input;
return { ...rest, workspaceOverwrites: projectOverwrites } as unknown as T;
}
// Drop stale projectOverwrites if workspaceOverwrites is already present
if ("projectOverwrites" in input && "workspaceOverwrites" in input) {
const { projectOverwrites: _, ...rest } = input;
return rest as unknown as T;
}
return input;
};
// ---------------------------------------------------------------------------
// Output transformation: include legacy `projectOverwrites` alongside `workspaceOverwrites`
// ---------------------------------------------------------------------------
/**
* Add `projectOverwrites` to a survey response object, mirroring `workspaceOverwrites`.
*/
export const addLegacyProjectOverwrites = <T extends Record<string, unknown>>(survey: T): T => {
if ("workspaceOverwrites" in survey) {
return { ...survey, projectOverwrites: survey.workspaceOverwrites };
}
return survey;
};
/**
* Add `projectOverwrites` to each survey in a list response.
*/
export const addLegacyProjectOverwritesToList = <T extends Record<string, unknown>>(surveys: T[]): T[] =>
surveys.map(addLegacyProjectOverwrites);
// ---------------------------------------------------------------------------
// Environment state output: include legacy `project` key alongside `workspace`
// ---------------------------------------------------------------------------
/**
* Enrich an environment state data object to include legacy `project` key
* alongside `workspace` so old SDK consumers still see the field they expect.
*/
export const addLegacyProjectToEnvironmentState = <T extends Record<string, unknown>>(data: T): T => {
if ("workspace" in data && !("project" in data)) {
return { ...data, project: data.workspace };
}
return data;
};
+1 -1
View File
@@ -1,10 +1,10 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TLanguage } from "@formbricks/types/workspace";
import { import {
DateRange, DateRange,
SelectedFilterValue, SelectedFilterValue,
+4 -5
View File
@@ -1647,7 +1647,7 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [ elements: [
buildMultipleChoiceElement({ buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle, type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[workspaceName]?", headline: "What's your primary goal for using $[projectName]?",
required: true, required: true,
shuffleOption: "none", shuffleOption: "none",
choices: [ choices: [
@@ -4815,7 +4815,7 @@ export const customSurveyTemplate = (t: TFunction): TTemplate => {
}; };
}; };
export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => { export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
return { return {
id: "cltxxaa6x0000g8hacxdxejeu", id: "cltxxaa6x0000g8hacxdxejeu",
createdAt: new Date(), createdAt: new Date(),
@@ -4823,7 +4823,6 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
name: t("templates.preview_survey_name"), name: t("templates.preview_survey_name"),
type: "link" as const, type: "link" as const,
environmentId: "cltwumfcz0009echxg02fh7oa", environmentId: "cltwumfcz0009echxg02fh7oa",
workspaceId: null,
createdBy: "cltwumfbz0000echxysz6ptvq", createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const, status: "inProgress" as const,
welcomeCard: { welcomeCard: {
@@ -4879,7 +4878,7 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
id: "lbdxozwikh838yc6a8vbwuju", id: "lbdxozwikh838yc6a8vbwuju",
range: 5, range: 5,
scale: "star", scale: "star",
headline: t("templates.preview_survey_question_1_headline", { workspaceName }), headline: t("templates.preview_survey_question_1_headline", { projectName }),
required: true, required: true,
subheader: t("templates.preview_survey_question_1_subheader"), subheader: t("templates.preview_survey_question_1_subheader"),
lowerLabel: t("templates.preview_survey_question_1_lower_label"), lowerLabel: t("templates.preview_survey_question_1_lower_label"),
@@ -4915,7 +4914,7 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
autoComplete: 50, autoComplete: 50,
isVerifyEmailEnabled: false, isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false, isSingleResponsePerEmailEnabled: false,
workspaceOverwrites: null, projectOverwrites: null,
surveyClosedMessage: null, surveyClosedMessage: null,
singleUse: { singleUse: {
enabled: false, enabled: false,

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