mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 21:59:28 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 122de0121e | |||
| eb416d7ff7 | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 | |||
| 83bc272ed2 |
+5
-9
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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 = ({
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -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;
|
||||||
|
|||||||
+3
-3
@@ -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"
|
||||||
|
|||||||
+6
-6
@@ -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(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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"
|
||||||
|
|||||||
+31
-31
@@ -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"
|
||||||
+13
-17
@@ -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",
|
||||||
|
|||||||
+2
-2
@@ -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"),
|
||||||
|
|||||||
+15
-15
@@ -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}
|
||||||
+62
-62
@@ -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>
|
||||||
|
|||||||
+33
-33
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+7
-7
@@ -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) {
|
||||||
|
|||||||
+8
-8
@@ -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}
|
||||||
|
|||||||
+12
-14
@@ -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(
|
||||||
|
|||||||
+10
-10
@@ -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: {
|
||||||
|
|||||||
+1
-1
@@ -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: {
|
||||||
|
|||||||
-7
@@ -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"),
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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) {
|
||||||
|
|||||||
+9
-9
@@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-13
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+3
-3
@@ -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} />
|
||||||
|
|||||||
+4
-4
@@ -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(() => {
|
||||||
|
|||||||
+3
-3
@@ -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");
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-5
@@ -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")}
|
||||||
|
|||||||
+5
-5
@@ -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
-2
@@ -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
-1
@@ -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
-1
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+5
-5
@@ -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) => (
|
||||||
|
|||||||
+3
-3
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -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",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+16
-25
@@ -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[]> => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user