chore: remove environment layer and replace with workspace (Phase 12) (#7687)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dhruwang Jariwala
2026-04-09 11:18:33 +05:30
committed by GitHub
parent 8073c0cc7b
commit 24b5425c88
465 changed files with 3004 additions and 5706 deletions
@@ -105,7 +105,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -45,7 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -36,7 +36,7 @@ export const OnboardingSetupInstructions = ({
!function(){
var appUrl = "${publicDomain}";
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -46,7 +46,7 @@ export const OnboardingSetupInstructions = ({
!function(){
var appUrl = "${publicDomain}";
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -18,11 +18,10 @@ import { createSurveyAction } from "@/modules/survey/components/template-list/ac
interface XMTemplateListProps {
workspace: TWorkspace;
user: TUser;
environmentId: string;
workspaceId: string;
}
export const XMTemplateList = ({ workspace, user, environmentId, workspaceId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -34,7 +33,7 @@ export const XMTemplateList = ({ workspace, user, environmentId, workspaceId }:
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
workspaceId: workspaceId,
surveyBody: augmentedTemplate,
});
@@ -28,7 +28,6 @@ const mockWorkspace: TWorkspace = {
clickOutsideClose: true,
overlay: "none",
appSetupCompleted: false,
environments: [],
languages: [],
logo: null,
};
@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getEnvironments } from "@/lib/environment/service";
import { getUser } from "@/lib/user/service";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -36,23 +35,12 @@ const Page = async (props: XMTemplatePageProps) => {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const environments = await getEnvironments(params.workspaceId);
const environment = environments[0];
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), null);
}
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList
workspace={workspace}
user={user}
environmentId={environment.id}
workspaceId={params.workspaceId}
/>
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
@@ -27,12 +27,6 @@ const SurveyEditorWorkspaceLayout = async (props: {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const environment = workspace.environments[0];
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), null);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
@@ -31,7 +31,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment, isBilling, workspace } = await getWorkspaceAuth(params.workspaceId);
const { isReadOnly, isBilling, workspace } = await getWorkspaceAuth(params.workspaceId);
const [
integrations,
@@ -62,7 +62,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
const appSetupCompleted = !!environment?.appSetupCompleted;
const appSetupCompleted = !!workspace.appSetupCompleted;
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier",
@@ -88,7 +88,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
);
const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
@@ -113,7 +113,7 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
});
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
@@ -1,7 +0,0 @@
"use client";
// Environments are deprecated — only production environments exist now.
// This component is kept as a no-op for any remaining references.
export const EnvironmentSwitch = () => {
return null;
};
@@ -332,7 +332,7 @@ export const MainNavigation = ({
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearEnvironmentId: true,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
@@ -1,15 +1,12 @@
"use client";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { useEnvironment } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentWorkspaceId: string;
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
@@ -20,9 +17,7 @@ interface TopControlBarProps {
}
export const TopControlBar = ({
environments,
currentOrganizationId,
currentWorkspaceId,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isFormbricksCloud,
@@ -32,17 +27,15 @@ export const TopControlBar = ({
membershipRole,
}: TopControlBarProps) => {
const { isMember } = getAccessFlags(membershipRole);
const { environment } = useEnvironment();
const { workspace } = useWorkspaceContext();
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<WorkspaceAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentWorkspaceId={workspace.id}
currentOrganizationId={currentOrganizationId}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
@@ -3,15 +3,14 @@
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
interface WidgetStatusIndicatorProps {
environment: TEnvironment;
workspace: { appSetupCompleted: boolean };
}
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => {
const { t } = useTranslation();
const router = useRouter();
const stati = {
@@ -29,7 +28,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
let status: "notImplemented" | "running";
if (environment.appSetupCompleted) {
if (workspace.appSetupCompleted) {
status = "running";
} else {
status = "notImplemented";
@@ -6,16 +6,16 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
interface EnvironmentLayoutProps {
layoutData: TEnvironmentLayoutData;
interface WorkspaceLayoutProps {
layoutData: TWorkspaceLayoutData;
children?: React.ReactNode;
}
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
@@ -25,7 +25,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
organization,
membership,
workspace, // Current workspace details
environments, // All workspace environments (for environment switcher)
isAccessControlAllowed,
workspacePermission,
license,
@@ -71,9 +70,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentWorkspaceId={workspace.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -5,15 +5,14 @@ import { FORMBRICKS_ENVIRONMENT_ID_LS, FORMBRICKS_WORKSPACE_ID_LS } from "@/lib/
interface WorkspaceStorageHandlerProps {
workspaceId: string;
environmentId: string;
}
const WorkspaceStorageHandler = ({ workspaceId, environmentId }: WorkspaceStorageHandlerProps) => {
const WorkspaceStorageHandler = ({ workspaceId }: WorkspaceStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_WORKSPACE_ID_LS, workspaceId);
// Keep environment ID in sync for backward compatibility with old SDK clients
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [workspaceId, environmentId]);
// Keep legacy environment ID in sync for backward compatibility with old SDK clients
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, workspaceId);
}, [workspaceId]);
return null;
};
@@ -25,13 +25,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useOrganization, useWorkspace } from "../context/environment-context";
import { useOrganization, useWorkspace } from "../context/workspace-context";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
currentWorkspaceId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
@@ -52,7 +52,7 @@ export const OrganizationBreadcrumb = ({
currentOrganizationId,
currentOrganizationName,
isMultiOrgEnabled,
currentEnvironmentId,
currentWorkspaceId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
@@ -241,7 +241,7 @@ export const OrganizationBreadcrumb = ({
)}
</>
)}
{currentEnvironmentId && (
{currentWorkspaceId && (
<div>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
@@ -9,8 +9,6 @@ interface WorkspaceAndOrgSwitchProps {
currentOrganizationName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
@@ -25,7 +23,6 @@ export const WorkspaceAndOrgSwitch = ({
currentOrganizationName,
currentWorkspaceId,
currentWorkspaceName,
currentEnvironmentId,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isFormbricksCloud,
@@ -40,13 +37,13 @@ export const WorkspaceAndOrgSwitch = ({
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentWorkspaceId && currentEnvironmentId && (
{currentWorkspaceId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
@@ -20,7 +20,7 @@ import {
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/environment-context";
import { useWorkspace } from "../context/workspace-context";
interface WorkspaceBreadcrumbProps {
currentWorkspaceId: string;
@@ -1,29 +1,27 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TWorkspace } from "@formbricks/types/workspace";
export interface EnvironmentContextType {
environment: TEnvironment;
export interface WorkspaceContextType {
workspace: TWorkspace;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
const WorkspaceContext = createContext<WorkspaceContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
export const useWorkspaceContext = () => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
throw new Error("useWorkspaceContext must be used within a WorkspaceContextWrapper");
}
return context;
};
export const useWorkspace = () => {
const context = useContext(EnvironmentContext);
const context = useContext(WorkspaceContext);
if (!context) {
return { workspace: null };
}
@@ -31,7 +29,7 @@ export const useWorkspace = () => {
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
const context = useContext(WorkspaceContext);
if (!context) {
return { organization: null };
}
@@ -39,30 +37,25 @@ export const useOrganization = () => {
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
interface WorkspaceContextWrapperProps {
workspace: TWorkspace;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
export const WorkspaceContextWrapper = ({
workspace,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
}: WorkspaceContextWrapperProps) => {
const workspaceContextValue = useMemo(
() => ({
environment,
workspace,
organization,
organizationId: workspace.organizationId,
}),
[environment, workspace, organization]
[workspace, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
return <WorkspaceContext.Provider value={workspaceContextValue}>{children}</WorkspaceContext.Provider>;
};
@@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/workspaces/[workspaceId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
@@ -22,13 +22,10 @@ const WorkspaceLayout = async (props: {
return (
<>
<WorkspaceStorageHandler workspaceId={params.workspaceId} environmentId={layoutData.environment.id} />
<EnvironmentContextWrapper
environment={layoutData.environment}
workspace={layoutData.workspace}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
</WorkspaceContextWrapper>
</>
);
};
@@ -2,11 +2,10 @@
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AccountSettingsNavbarProps {
environmentId?: string;
activeId: string;
loading?: boolean;
}
@@ -4,7 +4,7 @@ import { HelpCircleIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TUser } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
@@ -12,7 +12,6 @@ import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: TUser;
environmentId: string;
autoDisableNotificationType: string;
autoDisableNotificationElementId: string;
}
@@ -20,7 +19,6 @@ interface EditAlertsProps {
export const EditAlerts = ({
memberships,
user,
environmentId: _environmentId,
autoDisableNotificationType,
autoDisableNotificationElementId,
}: EditAlertsProps) => {
@@ -68,33 +66,27 @@ export const EditAlerts = ({
</TooltipProvider>
</div>
{membership.organization.workspaces.some((workspace) =>
workspace.environments.some((environment) => environment.surveys.length > 0)
) ? (
{membership.organization.workspaces.some((workspace) => workspace.surveys.length > 0) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.organization.workspaces.map((workspace) => (
<div key={workspace.id}>
{workspace.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div>
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrWorkspaceOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
</div>
))}
{workspace.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className="col-span-2 text-left">
<div className="font-medium text-slate-900">{survey.name}</div>
<div className="text-xs text-slate-400">{workspace.name}</div>
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrWorkspaceOrOrganizationId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
</div>
))}
</div>
@@ -1,14 +1,10 @@
"use client";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { SlackIcon } from "@/modules/ui/components/icons";
interface IntegrationsTipProps {
environmentId: string;
}
export const IntegrationsTip = ({ environmentId: _environmentId }: IntegrationsTipProps) => {
export const IntegrationsTip = () => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
return (
@@ -24,14 +24,12 @@ const setCompleteNotificationSettings = (
for (const membership of memberships) {
for (const workspace of membership.organization.workspaces) {
// set default values for alerts
for (const environment of workspace.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
for (const survey of workspace.surveys) {
newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
}
}
@@ -115,18 +113,10 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
surveys: {
select: {
id: true,
surveys: {
select: {
id: true,
name: true,
},
},
name: true,
},
},
},
@@ -143,7 +133,6 @@ const Page = async (props: {
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
@@ -167,7 +156,7 @@ const Page = async (props: {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar environmentId={params.workspaceId} activeId="notifications" />
<AccountSettingsNavbar activeId="notifications" />
</PageHeader>
<SettingsCard
title={t("workspace.settings.notifications.email_alerts_surveys")}
@@ -175,12 +164,11 @@ const Page = async (props: {
<EditAlerts
memberships={memberships}
user={user}
environmentId={params.workspaceId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</SettingsCard>
<IntegrationsTip environmentId={params.workspaceId} />
<IntegrationsTip />
</PageContentWrapper>
);
};
@@ -7,12 +7,9 @@ export interface Membership {
workspaces: {
id: string;
name: string;
environments: {
surveys: {
id: string;
surveys: {
id: string;
name: string;
}[];
name: string;
}[];
}[];
};
@@ -97,7 +97,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
clearWorkspaceId: true,
});
return;
}
@@ -141,7 +141,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/auth/login",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
clearWorkspaceId: true,
});
} else {
const errorMessage = getFormattedErrorMessage(result);
@@ -20,8 +20,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const t = await getTranslate();
const { workspaceId } = params;
const { session } = await getWorkspaceAuth(params.workspaceId);
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
@@ -37,7 +35,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar environmentId={workspaceId} activeId="profile" />
<AccountSettingsNavbar activeId="profile" />
</PageHeader>
{user && (
<div>
@@ -3,7 +3,7 @@
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
@@ -11,13 +11,10 @@ interface SurveyWithSlug {
name: string;
slug: string | null;
status: TSurveyStatus;
environment: {
workspace: {
id: string;
type: "production" | "development";
workspace: {
id: string;
name: string;
};
name: string;
organizationId: string;
};
createdAt: Date;
}
@@ -29,10 +26,6 @@ interface PrettyUrlsTableProps {
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
const { t } = useTranslation();
const getEnvironmentBadgeColor = () => {
return "bg-green-100 text-green-800";
};
const tableHeaders = [
{
label: t("workspace.settings.domain.survey_name"),
@@ -46,10 +39,6 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
label: t("workspace.settings.domain.pretty_url"),
key: "slug",
},
{
label: t("common.environment"),
key: "environment",
},
];
return (
@@ -67,7 +56,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
<TableBody className="[&_tr:last-child]:border-b">
{surveys.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center text-slate-500">
<TableCell colSpan={3} className="text-center text-slate-500">
{t("workspace.settings.domain.no_pretty_urls")}
</TableCell>
</TableRow>
@@ -76,20 +65,15 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">
<Link
href={`/workspaces/${survey.environment.workspace.id}/surveys/${survey.id}/summary`}
href={`/workspaces/${survey.workspace.id}/surveys/${survey.id}/summary`}
className="text-slate-900 hover:text-slate-700 hover:underline">
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.workspace.name}</TableCell>
<TableCell>{survey.workspace.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>
<TableCell>
<span className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor()}`}>
{t("common.production")}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -55,7 +55,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
<FaviconCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
environmentId={params.workspaceId}
workspaceId={params.workspaceId}
isReadOnly={!isOwnerOrManager}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
@@ -23,7 +23,6 @@ import {
import { Input } from "@/modules/ui/components/input";
interface EditOrganizationNameProps {
environmentId: string;
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
@@ -53,16 +53,12 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
<SettingsCard
title={t("workspace.settings.general.organization_name")}
description={t("workspace.settings.general.organization_name_description")}>
<EditOrganizationNameForm
organization={organization}
environmentId={params.workspaceId}
membershipRole={currentUserMembership?.role}
/>
<EditOrganizationNameForm organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
environmentId={params.workspaceId}
workspaceId={params.workspaceId}
isReadOnly={!isOwnerOrManager}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
fbLogoUrl={FB_LOGO_URL}
@@ -3,7 +3,7 @@
import { Unplug } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { Button } from "@/modules/ui/components/button";
export const EmptyAppSurveys = () => {
@@ -4,7 +4,7 @@ import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { revalidateSurveyIdPath } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/actions";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
@@ -1,7 +1,6 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
@@ -22,7 +21,6 @@ interface ResponseCardModalProps {
selectedResponseId: string | null;
setSelectedResponseId: (id: string | null) => void;
survey: TSurvey;
environment: TEnvironment;
user?: TUser;
environmentTags: TTag[];
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
@@ -38,7 +36,6 @@ export const ResponseCardModal = ({
selectedResponseId,
setSelectedResponseId,
survey,
environment,
user,
environmentTags,
updateResponse,
@@ -110,7 +107,6 @@ export const ResponseCardModal = ({
survey={survey}
response={responses[currentIndex]}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
@@ -3,7 +3,6 @@
import { TFunction } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseDataValue, TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -16,7 +15,6 @@ interface ResponseDataViewProps {
survey: TSurvey;
responses: TResponseWithQuotas[];
user?: TUser;
environment: TEnvironment;
environmentTags: TTag[];
isReadOnly: boolean;
fetchNextPage: () => void;
@@ -120,7 +118,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
survey,
responses,
user,
environment,
environmentTags,
isReadOnly,
fetchNextPage,
@@ -148,7 +145,6 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
user={user}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
environment={environment}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
updateResponseList={updateResponseList}
@@ -2,7 +2,6 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -16,7 +15,6 @@ import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
user?: TUser;
@@ -30,7 +28,6 @@ interface ResponsePageProps {
}
export const ResponsePage = ({
environment,
survey,
surveyId,
user,
@@ -145,7 +142,6 @@ export const ResponsePage = ({
survey={survey}
responses={responses}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
fetchNextPage={fetchNextPage}
@@ -18,7 +18,6 @@ import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -49,7 +48,6 @@ interface ResponseTableProps {
data: TResponseTableData[];
survey: TSurvey;
responses: TResponseWithQuotas[] | null;
environment: TEnvironment;
user?: TUser;
environmentTags: TTag[];
isReadOnly: boolean;
@@ -70,7 +68,6 @@ export const ResponseTable = ({
survey,
responses,
user,
environment,
environmentTags,
isReadOnly,
fetchNextPage,
@@ -310,7 +307,6 @@ export const ResponseTable = ({
survey={survey}
responses={responses}
user={user}
environment={environment}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
@@ -21,9 +21,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly, workspace } = await getWorkspaceAuth(
params.workspaceId
);
const { session, organization, isReadOnly, workspace } = await getWorkspaceAuth(params.workspaceId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
@@ -66,7 +64,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
@@ -81,7 +78,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
<SurveyAnalysisNavigation survey={survey} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
survey={survey}
surveyId={params.surveyId}
environmentTags={tags}
@@ -138,7 +138,7 @@ export const getEmailHtmlAction = authenticatedActionClient
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
workspaceId: ZId,
expirationDays: z.number().optional(),
});
@@ -148,7 +148,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
throw new OperationNotAllowedError("Contacts are not enabled for this workspace");
}
await checkAuthorizationUpdated({
@@ -217,7 +217,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
const ZUpdateSingleUseLinksAction = z.object({
surveyId: ZId,
environmentId: ZId,
workspaceId: ZId,
isSingleUse: z.boolean(),
isSingleUseEncryption: z.boolean(),
});
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response";
@@ -8,7 +8,6 @@ import { ElementSummaryHeader } from "./ElementSummaryHeader";
interface CalSummaryProps {
elementSummary: TSurveyElementSummaryCal;
environmentId: string;
survey: TSurvey;
}
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response";
@@ -5,7 +5,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
@@ -5,7 +5,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
@@ -5,7 +5,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
@@ -4,26 +4,26 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspaceContext();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
const isAppSurvey = survey.type === "app";
const appSetupCompleted = environment.appSetupCompleted;
const appSetupCompleted = workspace.appSetupCompleted;
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
if (newSurveyParam && survey && workspace) {
setConfetti(true);
toast.success(
isAppSurvey && !appSetupCompleted
@@ -47,7 +47,7 @@ export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) =>
window.history.replaceState({}, "", url.toString());
}
}, [environment, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
}, [workspace, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
return <>{confetti && <Confetti />}</>;
};
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
@@ -2,13 +2,12 @@
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { EmptyAppSurveys } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import {
SelectedFilterValue,
@@ -38,12 +37,12 @@ import { AddressSummary } from "./AddressSummary";
interface SummaryListProps {
summary: TSurveySummary["summary"];
responseCount: number | null;
environment: TEnvironment;
survey: TSurvey;
locale: TUserLocale;
}
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
const { workspace } = useWorkspaceContext();
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
const setFilter = (
@@ -100,7 +99,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
return (
<div className="mt-10 space-y-8">
{survey.type === "app" && responseCount === 0 && !environment.appSetupCompleted ? (
{survey.type === "app" && responseCount === 0 && !workspace.appSetupCompleted ? (
<EmptyAppSurveys />
) : summary.length === 0 ? (
<SkeletonLoader type="summary" />
@@ -199,12 +198,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
}
if (elementSummary.type === TSurveyElementTypeEnum.Cal) {
return (
<CalSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
environmentId={environment.id}
survey={survey}
/>
<CalSummary key={elementSummary.element.id} elementSummary={elementSummary} survey={survey} />
);
}
if (elementSummary.type === TSurveyElementTypeEnum.Matrix) {
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
@@ -45,7 +44,6 @@ const defaultSurveySummary: TSurveySummary = {
};
interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
locale: TUserLocale;
@@ -54,7 +52,6 @@ interface SummaryPageProps {
}
export const SummaryPage = ({
environment,
survey,
surveyId,
locale,
@@ -211,7 +208,6 @@ export const SummaryPage = ({
summary={surveySummary.summary}
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
environment={environment}
locale={locale}
/>
</>
@@ -5,18 +5,17 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { SuccessMessage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { copySurveyToOtherWorkspaceAction } from "@/modules/survey/list/actions";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
@@ -24,7 +23,6 @@ import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -42,7 +40,6 @@ interface ModalState {
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -64,10 +61,10 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { workspace } = useEnvironment();
const { workspace } = useWorkspaceContext();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
const appSetupCompleted = survey.type === "app" && workspace.appSetupCompleted;
useEffect(() => {
setModalState((prev) => ({
@@ -93,9 +90,9 @@ export const SurveyAnalysisCTA = ({
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
const duplicatedSurveyResponse = await copySurveyToOtherWorkspaceAction({
surveyId: surveyId,
targetEnvironmentId: environment.id,
targetWorkspaceId: workspace.id,
});
if (duplicatedSurveyResponse?.data) {
toast.success(t("workspace.surveys.survey_duplicated_successfully"));
@@ -183,7 +180,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
<SurveyStatusDropdown survey={survey} />
)}
<IconBar actions={iconActions} />
@@ -215,7 +212,7 @@ export const SurveyAnalysisCTA = ({
workspaceCustomScripts={workspace.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
<SuccessMessage survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -70,7 +70,6 @@ export const ShareSurveyModal = ({
isStorageConfigured,
workspaceCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
@@ -103,7 +102,7 @@ export const ShareSurveyModal = ({
description: t("workspace.surveys.share.personal_links.description"),
componentType: PersonalLinksTab,
componentProps: {
environmentId,
workspaceId: survey.workspaceId,
surveyId: survey.id,
segments,
isContactsEnabled,
@@ -163,7 +162,7 @@ export const ShareSurveyModal = ({
title: t("workspace.surveys.share.dynamic_popup.nav_title"),
description: t("workspace.surveys.share.dynamic_popup.description"),
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
componentProps: { surveyId: survey.id },
},
{
id: ShareSettingsType.LINK_SETTINGS,
@@ -210,7 +209,7 @@ export const ShareSurveyModal = ({
user.locale,
surveyUrl,
isReadOnly,
environmentId,
survey.workspaceId,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -70,7 +70,7 @@ export const AnonymousLinksTab = ({
try {
const updatedSurveyResponse = await updateSingleUseLinksAction({
surveyId: survey.id,
environmentId: survey.environmentId,
workspaceId: survey.workspaceId,
isSingleUse,
isSingleUseEncryption,
});
@@ -13,7 +13,7 @@ import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSegment } from "@formbricks/types/segment";
import { useEnvironment } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { H4, InlineSmall, Small } from "@/modules/ui/components/typography";
@@ -88,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
export const AppTab = () => {
const { t } = useTranslation();
const { environment, workspace } = useEnvironment();
const { workspace } = useWorkspaceContext();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
@@ -151,18 +151,18 @@ export const AppTab = () => {
return (
<div className="flex flex-col justify-between space-y-6 pb-4">
<div className="flex flex-col space-y-6">
<Alert variant={environment.appSetupCompleted ? "success" : "warning"} size="default">
<Alert variant={workspace.appSetupCompleted ? "success" : "warning"} size="default">
<AlertTitle>
{environment.appSetupCompleted
{workspace.appSetupCompleted
? t("workspace.surveys.summary.in_app.connection_title")
: t("workspace.surveys.summary.in_app.no_connection_title")}
</AlertTitle>
<AlertDescription>
{environment.appSetupCompleted
{workspace.appSetupCompleted
? t("workspace.surveys.summary.in_app.connection_description")
: t("workspace.surveys.summary.in_app.no_connection_description")}
</AlertDescription>
{!environment.appSetupCompleted && (
{!workspace.appSetupCompleted && (
<AlertButton asChild>
<Link href={`/workspaces/${workspace?.id}/app-connection`}>
{t("common.connect_formbricks")}
@@ -2,16 +2,15 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { DocumentationLinks } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
interface DynamicPopupTabProps {
environmentId: string;
surveyId: string;
}
export const DynamicPopupTab = ({ environmentId: _environmentId, surveyId }: DynamicPopupTabProps) => {
export const DynamicPopupTab = ({ surveyId }: DynamicPopupTabProps) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
@@ -233,7 +233,7 @@ export const LinkSettingsTab = ({ isReadOnly, locale, isStorageConfigured }: Lin
<FileInput
id={`og-image-upload-${survey.id}`}
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={survey.environmentId}
workspaceId={survey.workspaceId}
onFileUpload={handleFileUpload}
fileUrl={field.value}
maxSizeInMB={5}
@@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSegment } from "@formbricks/types/segment";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { DocumentationLinks } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -30,7 +30,7 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
workspaceId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
@@ -70,7 +70,7 @@ const RestrictedDatePicker = ({
};
export const PersonalLinksTab = ({
environmentId,
workspaceId,
segments,
surveyId,
isContactsEnabled,
@@ -117,7 +117,7 @@ export const PersonalLinksTab = ({
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
workspaceId: workspaceId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
@@ -4,7 +4,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/environment-context";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import {
ShareSettingsType,
ShareViaType,
@@ -2,7 +2,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
@@ -12,7 +12,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId);
}
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
const workspace = await getWorkspace(survey.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
@@ -21,9 +21,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
const params = await props.params;
const t = await getTranslate();
const { session, environment, isReadOnly, workspace, organization } = await getWorkspaceAuth(
params.workspaceId
);
const { session, isReadOnly, workspace, organization } = await getWorkspaceAuth(params.workspaceId);
const surveyId = params.surveyId;
@@ -66,7 +64,6 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
@@ -81,7 +78,6 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
<SurveyAnalysisNavigation survey={survey} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={params.surveyId}
locale={user.locale ?? DEFAULT_LOCALE}
@@ -3,8 +3,8 @@
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -17,16 +17,12 @@ import {
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
const { workspace } = useWorkspaceContext();
const { t } = useTranslation();
const router = useRouter();
@@ -72,7 +68,7 @@ export const SurveyStatusDropdown = ({
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
<SelectValue>
<div className="flex items-center">
{(survey.type === "link" || environment.appSetupCompleted) && (
{(survey.type === "link" || workspace.appSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<span className="ml-2 text-sm text-slate-700">
@@ -1,8 +1,8 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (
@@ -17,7 +17,7 @@ export const GET = async (
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const workspace = await getWorkspaceByEnvironmentId(environmentId);
const workspace = await findWorkspaceByIdOrLegacyEnvId(environmentId);
if (!workspace) return notFound();
const hasAccess = await hasUserWorkspaceAccess(session.user.id, workspace.id);
@@ -1,8 +1,8 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (_: Request, context: { params: Promise<{ environmentId: string }> }) => {
@@ -14,7 +14,7 @@ export const GET = async (_: Request, context: { params: Promise<{ environmentId
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const workspace = await getWorkspaceByEnvironmentId(environmentId);
const workspace = await findWorkspaceByIdOrLegacyEnvId(environmentId);
if (!workspace) return notFound();
const hasAccess = await hasUserWorkspaceAccess(session.user.id, workspace.id);
@@ -87,7 +87,7 @@ export const mockSurvey: TSurvey = {
updatedAt: new Date(),
name: "Start from scratch",
type: "link",
environmentId: "cm98djl8e000919hpzi6a80zp",
workspaceId: "cm98djl8e000919hpzi6a80zp",
createdBy: "cm98dg3xm000019hpubj39vfi",
status: "inProgress",
welcomeCard: {
@@ -58,7 +58,7 @@ const hiddenFieldId = "hidden1";
const variableId = "var1";
const mockPipelineInput = {
environmentId: "env1",
workspaceId: "env1",
surveyId: surveyId,
response: {
id: "response1",
@@ -158,7 +158,7 @@ const mockSurvey = {
updatedAt: new Date(),
displayOption: "displayOnce",
displayPercentage: null,
environmentId: "env1",
workspaceId: "env1",
singleUse: null,
surveyClosedMessage: null,
pin: null,
@@ -167,7 +167,7 @@ const mockSurvey = {
const mockAirtableIntegration: TIntegrationAirtable = {
id: "int_airtable",
type: "airtable",
environmentId: "env1",
workspaceId: "env1",
config: {
key: { access_token: "airtable_key" } as TIntegrationAirtableCredential,
data: [
@@ -189,7 +189,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
id: "int_gsheets",
type: "googleSheets",
environmentId: "env1",
workspaceId: "env1",
config: {
key: { refresh_token: "gsheet_key" } as TIntegrationGoogleSheetsCredential,
data: [
@@ -212,7 +212,7 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
const mockSlackIntegration: TIntegrationSlack = {
id: "int_slack",
type: "slack",
environmentId: "env1",
workspaceId: "env1",
config: {
key: { access_token: "slack_key", app_id: "A1" } as TIntegrationSlackCredential,
data: [
@@ -235,7 +235,7 @@ const mockSlackIntegration: TIntegrationSlack = {
const mockNotionIntegration: TIntegrationNotion = {
id: "int_notion",
type: "notion",
environmentId: "env1",
workspaceId: "env1",
config: {
key: {
access_token: "notion_key",
@@ -38,7 +38,6 @@ export const POST = async (request: Request) => {
jsonInput,
new Set(["contactAttributes", "variables", "data", "meta"])
);
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) {
@@ -5,7 +5,6 @@ import { ZResponse } from "@formbricks/types/responses";
export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
environmentId: z.string(),
workspaceId: z.string(),
surveyId: z.string(),
});
+7 -33
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth";
@@ -20,19 +20,10 @@ describe("getApiKeyWithPermissions", () => {
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
apiKeyWorkspaces: [
{
environmentId: "env-1",
workspaceId: "workspace-1",
permission: "manage" as const,
environment: {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production" as const,
workspaceId: "workspace-1",
appSetupCompleted: true,
},
workspace: { id: "workspace-1", name: "Workspace 1" },
},
],
@@ -56,25 +47,19 @@ describe("getApiKeyWithPermissions", () => {
});
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
const permissions: TAPIKeyWorkspacePermission[] = [
{
environmentId: "env-1",
permission: "manage",
environmentType: "production",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
workspaceId: "workspace-2",
workspaceName: "Workspace 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "production",
workspaceId: "workspace-3",
workspaceName: "Workspace 3",
},
@@ -117,19 +102,10 @@ describe("authenticateRequest", () => {
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
apiKeyWorkspaces: [
{
environmentId: "env-1",
workspaceId: "workspace-1",
permission: "manage" as const,
environment: {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production" as const,
workspaceId: "workspace-1",
appSetupCompleted: true,
},
workspace: { id: "workspace-1", name: "Workspace 1" },
},
],
@@ -140,11 +116,9 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [
workspacePermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "production",
workspaceId: "workspace-1",
workspaceName: "Workspace 1",
},
@@ -173,7 +147,7 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
test("returns null when API key has no environment permissions", async () => {
test("returns null when API key has no workspace permissions", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -186,7 +160,7 @@ describe("authenticateRequest", () => {
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [],
apiKeyWorkspaces: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
+2 -5
View File
@@ -13,14 +13,11 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
if (!apiKeyData) return null;
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
if (apiKeyData.apiKeyWorkspaces.length === 0) return null;
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
workspacePermissions: apiKeyData.apiKeyWorkspaces.map((env) => ({
permission: env.permission,
workspaceId: env.workspaceId,
workspaceName: env.workspace.name,
@@ -31,7 +31,6 @@ vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
const environmentId = "scgavd0rtce5xgahiresk4p0";
const workspaceId = "cqiu9au22kgzgqjossdlp5qh";
const surveyId = "kf4w7x11wut39ttl4j5v9ccg";
const userId = "test-user-id";
@@ -39,14 +38,12 @@ const contactId = "f9bufd72cffj19a7qj67z5fm";
const displayId = "apbycx5war0mfsyztgpwb8wr";
const displayInput: TDisplayCreateInput = {
environmentId,
workspaceId,
surveyId,
userId,
};
const displayInputWithoutUserId: TDisplayCreateInput = {
environmentId,
workspaceId,
surveyId,
};
@@ -80,7 +77,7 @@ const mockDisplayWithoutContact = {
const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
workspaceId,
} as any;
describe("createDisplay", () => {
@@ -122,8 +119,7 @@ describe("createDisplay", () => {
expect(getContactByUserId).toHaveBeenCalledWith(workspaceId, userId);
expect(prisma.contact.create).toHaveBeenCalledWith({
data: {
environment: { connect: { id: environmentId } },
workspace: { connect: { id: workspaceId } },
workspaceId,
attributes: {
create: {
attributeKey: {
@@ -8,7 +8,7 @@ import { getContactByUserId } from "./contact";
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<{ id: string }> => {
validateInputs([displayInput, ZDisplayCreateInput]);
const { environmentId, workspaceId, userId, surveyId } = displayInput;
const { workspaceId, userId, surveyId } = displayInput;
try {
let contact: { id: string } | null = null;
@@ -17,8 +17,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
if (!contact) {
contact = await prisma.contact.create({
data: {
environment: { connect: { id: environmentId } },
workspace: { connect: { id: workspaceId } },
workspaceId,
attributes: {
create: {
attributeKey: {
@@ -30,12 +30,11 @@ export const POST = withV1ApiWrapper({
response: responses.notFoundResponse("Workspace", params.workspaceId),
};
}
const { environmentId, workspaceId } = resolved;
const { workspaceId } = resolved;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId,
workspaceId,
});
@@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsWorkspaceState, TJsWorkspaceStateWorkspaceSetting } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TJsWorkspaceStateWorkspaceSetting } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { WorkspaceStateData, getWorkspaceStateData } from "./data";
@@ -11,6 +10,8 @@ import { getWorkspaceState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("server-only", () => ({}));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -67,7 +68,6 @@ vi.mock("@formbricks/cache", () => ({
const workspaceId = "test-workspace-id";
const mockWorkspace: TJsWorkspaceStateWorkspaceSetting = {
id: workspaceId,
recontactDays: 30,
inAppSurveyBranding: true,
placement: "bottomRight",
@@ -84,7 +84,6 @@ const mockSurveys: TSurvey[] = [
createdAt: new Date(),
updatedAt: new Date(),
name: "App Survey In Progress",
environmentId: workspaceId,
type: "app",
status: "inProgress",
displayLimit: null,
@@ -117,7 +116,7 @@ const mockSurveys: TSurvey[] = [
} as unknown as TSurvey,
];
const mockActionClasses: TActionClass[] = [
const mockActionClasses = [
{
id: "action-1",
createdAt: new Date(),
@@ -129,7 +128,7 @@ const mockActionClasses: TActionClass[] = [
environmentId: workspaceId,
key: "action1",
},
];
] as unknown as TActionClass[];
const mockWorkspaceStateData: WorkspaceStateData = {
workspace: {
@@ -205,7 +205,6 @@ export const PUT = withV1ApiWrapper({
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
workspaceId: survey.workspaceId,
surveyId: survey.id,
response: responseData,
@@ -216,7 +215,6 @@ export const PUT = withV1ApiWrapper({
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
workspaceId: survey.workspaceId,
surveyId: survey.id,
response: responseData,
@@ -24,7 +24,7 @@ vi.mock("react", async () => {
});
const mockContactId = "test-contact-id";
const mockEnvironmentId = "test-env-id";
const mockWorkspaceId = "test-env-id";
const mockUserId = "test-user-id";
describe("Contact API Lib", () => {
@@ -36,11 +36,11 @@ describe("Contact API Lib", () => {
test("should return contact if found", async () => {
const mockContactData = {
id: mockContactId,
workspaceId: mockWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
};
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData as any);
const contact = await getContact(mockContactId);
@@ -82,19 +82,17 @@ describe("Contact API Lib", () => {
test("should return contact with formatted attributes if found", async () => {
const mockContactData = {
id: mockContactId,
workspaceId: mockWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
attributes: [
{ attributeKey: { key: "userId" }, value: mockUserId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(
mockContactData as Awaited<ReturnType<typeof prisma.contact.findFirst>>
);
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
const contact = await getContactByUserId(mockWorkspaceId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
@@ -102,7 +100,7 @@ describe("Contact API Lib", () => {
some: {
attributeKey: {
key: "userId",
workspaceId: mockEnvironmentId,
workspaceId: mockWorkspaceId,
},
value: mockUserId,
},
@@ -130,7 +128,7 @@ describe("Contact API Lib", () => {
test("should return null if contact not found by userId", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
const contact = await getContactByUserId(mockWorkspaceId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
@@ -138,7 +136,7 @@ describe("Contact API Lib", () => {
some: {
attributeKey: {
key: "userId",
workspaceId: mockEnvironmentId,
workspaceId: mockWorkspaceId,
},
value: mockUserId,
},
@@ -58,7 +58,6 @@ vi.mock("@/modules/ee/quotas/lib/evaluation-service", () => ({
evaluateResponseQuotas: vi.fn(),
}));
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
@@ -73,7 +72,6 @@ const mockOrganization = {
};
const mockResponseInput: TResponseInput = {
environmentId,
workspaceId,
surveyId,
userId: null,
@@ -45,7 +45,6 @@ export const responseSelection = {
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
@@ -73,11 +73,10 @@ export const POST = withV1ApiWrapper({
response: responses.notFoundResponse("Workspace", params.workspaceId),
};
}
const { environmentId, workspaceId } = resolved;
const { workspaceId } = resolved;
const responseInputValidation = ZResponseInput.safeParse({
...responseInput,
environmentId,
workspaceId,
});
@@ -190,7 +189,6 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
@@ -199,7 +197,6 @@ export const POST = withV1ApiWrapper({
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
workspaceId,
surveyId: responseData.surveyId,
response: responseData,
@@ -39,7 +39,7 @@ export const POST = withV1ApiWrapper({
response: responses.notFoundResponse("Workspace", params.workspaceId),
};
}
const { environmentId, workspaceId } = resolved;
const { workspaceId } = resolved;
let jsonInput: TUploadPrivateFileRequest;
@@ -54,7 +54,6 @@ export const POST = withV1ApiWrapper({
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
workspaceId,
});
@@ -88,7 +87,7 @@ export const POST = withV1ApiWrapper({
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
response: responses.notFoundResponse("OrganizationByWorkspaceId", workspaceId),
};
}
@@ -21,7 +21,7 @@ const fetchAndAuthorizeActionClass = async (
}
// Check if API key has permission to access this workspace with appropriate permissions
if (!hasPermission(authentication.environmentPermissions, actionClass.workspaceId, method)) {
if (!hasPermission(authentication.workspacePermissions, actionClass.workspaceId, method)) {
throw new Error("Unauthorized");
}
@@ -93,7 +93,7 @@ export const PUT = withV1ApiWrapper({
}
// Accept workspaceId as alternative to environmentId — resolve to production environment
const resolved = await resolveBodyIds(actionClassUpdate, authentication.environmentPermissions, "PUT");
const resolved = await resolveBodyIds(actionClassUpdate, authentication.workspacePermissions, "PUT");
if (!resolved.ok) return { response: resolved.response };
const inputValidation = ZActionClassInput.safeParse(resolved.body);
@@ -108,7 +108,7 @@ export const PUT = withV1ApiWrapper({
if (
!resolved.alreadyAuthorized &&
!hasPermission(authentication.environmentPermissions, inputValidation.data.workspaceId, "PUT")
!hasPermission(authentication.workspacePermissions, inputValidation.data.workspaceId, "PUT")
) {
return { response: responses.unauthorizedResponse() };
}
@@ -24,7 +24,7 @@ describe("getActionClasses", () => {
type: "code" as const,
key: "test-key-1" as string | null,
noCodeConfig: {},
environmentId: "env1",
workspaceId: "ws1",
},
{
id: "action2",
@@ -35,7 +35,7 @@ describe("getActionClasses", () => {
type: "noCode" as const,
key: "test-key-2" as string | null,
noCodeConfig: {},
environmentId: "env2",
workspaceId: "ws2",
},
];
@@ -18,7 +18,6 @@ const selectActionClass = {
type: true,
key: true,
noCodeConfig: true,
environmentId: true,
workspaceId: true,
} satisfies Prisma.ActionClassSelect;
@@ -17,7 +17,7 @@ export const GET = withV1ApiWrapper({
try {
const workspaceIds = [
...new Set(authentication.environmentPermissions.map((permission) => permission.workspaceId)),
...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)),
];
const actionClasses = await getActionClasses(workspaceIds);
@@ -53,8 +53,8 @@ export const POST = withV1ApiWrapper({
};
}
// Accept workspaceId as alternative to environmentId — resolve to production environment
const resolved = await resolveBodyIds(actionClassInput, authentication.environmentPermissions, "POST");
// Validate workspace-level permission
const resolved = await resolveBodyIds(actionClassInput, authentication.workspacePermissions, "POST");
if (!resolved.ok) return { response: resolved.response };
const inputValidation = ZActionClassInput.safeParse(resolved.body);
@@ -70,7 +70,7 @@ export const POST = withV1ApiWrapper({
if (
!resolved.alreadyAuthorized &&
!hasPermission(authentication.environmentPermissions, inputValidation.data.workspaceId, "POST")
!hasPermission(authentication.workspacePermissions, inputValidation.data.workspaceId, "POST")
) {
return { response: responses.unauthorizedResponse() };
}
@@ -1,90 +1,54 @@
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth";
import { responses } from "@/app/lib/api/response";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { hasWorkspacePermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/**
* Resolves a workspaceId to its production environment's ID (simple wrapper for v1 routes).
*/
export const getProductionEnvironmentIdByWorkspaceId = async (
workspaceId: string
): Promise<string | null> => {
const environment = await prisma.environment.findFirst({
where: { workspaceId, type: "production" },
select: { id: true },
});
return environment?.id ?? null;
};
/**
* Given a request body that contains either workspaceId or environmentId (but not both),
* resolves the missing identifier so the returned body is guaranteed to contain both.
* Given a request body that must contain workspaceId (or legacy environmentId),
* validates workspace-level permission and returns the authorized body.
*
* Returns `{ body, alreadyAuthorized: true }` when workspace-level auth was used,
* or `{ body, alreadyAuthorized: false }` when the caller still needs to check env-level permission.
* Returns an error response if authorization or resolution fails.
* Returns `{ body, alreadyAuthorized: true }` when workspace-level auth was used.
* Returns an error response if authorization fails.
*/
export const resolveBodyIds = async <T extends Record<string, unknown>>(
body: T,
permissions: TAPIKeyEnvironmentPermission[],
permissions: TAPIKeyWorkspacePermission[],
method: HttpMethod
): Promise<
| { ok: true; body: T & { environmentId: string; workspaceId: string }; alreadyAuthorized: boolean }
| { ok: true; body: T & { workspaceId: string }; alreadyAuthorized: boolean }
| { ok: false; response: Response }
> => {
if (body.workspaceId && body.environmentId) {
// Accept workspaceId or environmentId (legacy alias)
const rawId = (body.workspaceId ?? body.environmentId) as string | undefined;
if (!rawId) {
return {
ok: false,
response: responses.badRequestResponse("Provide either environmentId or workspaceId, not both"),
response: responses.badRequestResponse("workspaceId must be provided"),
};
}
if (body.workspaceId && !body.environmentId) {
if (typeof body.workspaceId !== "string") {
return { ok: false, response: responses.badRequestResponse("workspaceId must be a string") };
}
const workspaceId = body.workspaceId;
if (!hasWorkspacePermission(permissions, workspaceId, method)) {
return { ok: false, response: responses.unauthorizedResponse() };
}
const resolvedEnvId = await getProductionEnvironmentIdByWorkspaceId(workspaceId);
if (!resolvedEnvId) {
return { ok: false, response: responses.notFoundResponse("Workspace", workspaceId) };
}
return {
ok: true,
body: { ...body, environmentId: resolvedEnvId, workspaceId },
alreadyAuthorized: true,
};
if (typeof rawId !== "string") {
return { ok: false, response: responses.badRequestResponse("workspaceId must be a string") };
}
if (body.environmentId && !body.workspaceId) {
if (typeof body.environmentId !== "string") {
return { ok: false, response: responses.badRequestResponse("environmentId must be a string") };
}
// Resolve to canonical workspace id (handles legacy environment IDs)
const workspace = await findWorkspaceByIdOrLegacyEnvId(rawId);
if (!workspace) {
return { ok: false, response: responses.notFoundResponse("Workspace", rawId) };
}
let resolvedWorkspaceId: string;
try {
resolvedWorkspaceId = await getWorkspaceIdFromEnvironmentId(body.environmentId);
} catch {
return { ok: false, response: responses.notFoundResponse("Environment", body.environmentId) };
}
const workspaceId = workspace.id;
return {
ok: true,
body: { ...body, workspaceId: resolvedWorkspaceId, environmentId: body.environmentId },
alreadyAuthorized: false,
};
if (!hasPermission(permissions, workspaceId, method)) {
return { ok: false, response: responses.unauthorizedResponse() };
}
return {
ok: false,
response: responses.badRequestResponse("Either environmentId or workspaceId must be provided"),
ok: true,
body: { ...body, workspaceId },
alreadyAuthorized: true,
};
};
+5 -5
View File
@@ -13,7 +13,7 @@ const apiKeySelect = {
id: true,
organizationId: true,
lastUsedAt: true,
apiKeyEnvironments: {
apiKeyWorkspaces: {
select: {
environment: {
select: {
@@ -42,7 +42,7 @@ type ApiKeyData = {
hashedKey: string;
organizationId: string;
lastUsedAt: Date | null;
apiKeyEnvironments: Array<{
apiKeyWorkspaces: Array<{
permission: string;
environment: {
id: string;
@@ -117,7 +117,7 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyEnvironments[0].environment;
const env = apiKeyData.apiKeyWorkspaces[0].environment;
return Response.json({
id: env.id,
type: env.type,
@@ -136,9 +136,9 @@ const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
return (
apiKeyData.apiKeyEnvironments.length === 1 &&
apiKeyData.apiKeyWorkspaces.length === 1 &&
ALLOWED_PERMISSIONS.includes(
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
apiKeyData.apiKeyWorkspaces[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
)
);
};
@@ -51,7 +51,7 @@ describe("updateResponseWithQuotaEvaluation", () => {
name: "important",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
environmentId: "env123",
workspaceId: "workspace123",
},
],
};
@@ -31,7 +31,7 @@ async function fetchAndAuthorizeResponse(
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
}
if (!hasPermission(authentication.environmentPermissions, survey.workspaceId, requiredPermission)) {
if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
@@ -171,7 +171,6 @@ export const PUT = withV1ApiWrapper({
sendToPipeline({
event: "responseUpdated",
environmentId: result.survey.environmentId,
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
response: updated,
@@ -180,7 +179,6 @@ export const PUT = withV1ApiWrapper({
if (updated.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: result.survey.environmentId,
workspaceId: result.survey.workspaceId,
surveyId: result.survey.id,
response: updated,
@@ -1,9 +1,8 @@
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
@@ -13,7 +12,6 @@ import { getContactByUserId } from "./contact";
import { createResponse, getResponsesByWorkspaceIds } from "./response";
// Mock Data
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const mockUserId = "test-user-id";
const surveyId = "test-survey-id";
@@ -24,7 +22,6 @@ const mockOrganization = "test-organization-id";
const mockResponseInput: TResponseInput = {
workspaceId,
environmentId,
surveyId,
displayId,
finished: true,
@@ -49,7 +49,6 @@ export const responseSelection = {
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
workspaceId: true,
},
},
@@ -33,7 +33,7 @@ export const GET = withV1ApiWrapper({
response: responses.notFoundResponse("Survey", surveyId, true),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.workspaceId, "GET")) {
if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
@@ -42,7 +42,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...surveyResponses);
} else {
const workspaceIds = [
...new Set(authentication.environmentPermissions.map((permission) => permission.workspaceId)),
...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)),
];
const workspaceResponses = await getResponsesByWorkspaceIds(workspaceIds, limit, offset);
allResponses.push(...workspaceResponses);
@@ -101,7 +101,7 @@ export const POST = withV1ApiWrapper({
}
// Accept workspaceId as alternative to environmentId — resolve to production environment
const resolved = await resolveBodyIds(jsonInput, authentication.environmentPermissions, "POST");
const resolved = await resolveBodyIds(jsonInput, authentication.workspacePermissions, "POST");
if (!resolved.ok) return { response: resolved.response };
const inputValidation = ZResponseInput.safeParse(resolved.body);
@@ -119,7 +119,7 @@ export const POST = withV1ApiWrapper({
if (
!resolved.alreadyAuthorized &&
!hasPermission(authentication.environmentPermissions, responseInput.workspaceId, "POST")
!hasPermission(authentication.workspacePermissions, responseInput.workspaceId, "POST")
) {
return { response: responses.unauthorizedResponse() };
}
@@ -168,7 +168,6 @@ export const POST = withV1ApiWrapper({
sendToPipeline({
event: "responseCreated",
environmentId: surveyResult.survey.environmentId,
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
response: response,
@@ -177,7 +176,6 @@ export const POST = withV1ApiWrapper({
if (response.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: surveyResult.survey.environmentId,
workspaceId: surveyResult.survey.workspaceId,
surveyId: response.surveyId,
response: response,
@@ -2,7 +2,7 @@ import { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { checkAuth } from "./utils";
@@ -11,8 +11,8 @@ const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
vi.mock("@/lib/workspace/auth", () => ({
hasUserWorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
@@ -28,17 +28,17 @@ vi.mock("@/app/lib/api/response", () => ({
}));
describe("checkAuth", () => {
const environmentId = "env-123";
const workspaceId = "workspace-123";
test("returns notAuthenticatedResponse when authentication is null", async () => {
const result = await checkAuth(null, environmentId);
const result = await checkAuth(null, workspaceId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
const result = await checkAuth(undefined as any, environmentId);
const result = await checkAuth(undefined as any, workspaceId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
@@ -47,12 +47,10 @@ describe("checkAuth", () => {
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
workspacePermissions: [
{
environmentId: "env-123",
permission: "read",
environmentType: "production",
workspaceId: "workspace-1",
workspaceId: "workspace-123",
workspaceName: "Workspace 1",
},
],
@@ -65,11 +63,11 @@ describe("checkAuth", () => {
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(mockAuthentication, environmentId);
const result = await checkAuth(mockAuthentication, workspaceId);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
"workspace-1",
mockAuthentication.workspacePermissions,
"workspace-123",
"POST"
);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
@@ -79,12 +77,10 @@ describe("checkAuth", () => {
test("returns undefined when API key authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
workspacePermissions: [
{
environmentId: "env-123",
permission: "write",
environmentType: "production",
workspaceId: "workspace-1",
workspaceId: "workspace-123",
workspaceName: "Workspace 1",
},
],
@@ -97,17 +93,17 @@ describe("checkAuth", () => {
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(mockAuthentication, environmentId);
const result = await checkAuth(mockAuthentication, workspaceId);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
"workspace-1",
mockAuthentication.workspacePermissions,
"workspace-123",
"POST"
);
expect(result).toBeUndefined();
});
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
test("returns unauthorizedResponse when session exists but user lacks workspace access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
@@ -115,16 +111,16 @@ describe("checkAuth", () => {
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId);
const result = await checkAuth(mockSession, workspaceId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(hasUserWorkspaceAccess).toHaveBeenCalledWith("user-123", workspaceId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when session exists and user has environment access", async () => {
test("returns undefined when session exists and user has workspace access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
@@ -132,18 +128,18 @@ describe("checkAuth", () => {
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId);
const result = await checkAuth(mockSession, workspaceId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(hasUserWorkspaceAccess).toHaveBeenCalledWith("user-123", workspaceId);
expect(result).toBeUndefined();
});
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
const invalidAuth = { someProperty: "value" } as any;
const result = await checkAuth(invalidAuth, environmentId);
const result = await checkAuth(invalidAuth, workspaceId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
@@ -1,21 +1,20 @@
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const checkAuth = async (authentication: TApiV1Authentication | undefined, environmentId: string) => {
export const checkAuth = async (authentication: TApiV1Authentication | undefined, workspaceId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
if ("user" in authentication) {
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
const isUserAuthorized = await hasUserWorkspaceAccess(authentication.user.id, workspaceId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
} else if ("apiKeyId" in authentication) {
const perm = authentication.environmentPermissions.find((p) => p.environmentId === environmentId);
if (!perm || !hasPermission(authentication.environmentPermissions, perm.workspaceId, "POST")) {
if (!hasPermission(authentication.workspacePermissions, workspaceId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
@@ -1,14 +1,10 @@
import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import {
getProductionEnvironmentIdByWorkspaceId,
resolveBodyIds,
} from "@/app/api/v1/management/lib/workspace-resolver";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getWorkspaceIdFromEnvironmentId } from "@/lib/utils/helper";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
@@ -31,33 +27,14 @@ export const POST = withV1ApiWrapper({
};
}
// Accept workspaceId as alternative to environmentId
// Accept workspaceId
if (authentication && "apiKeyId" in authentication) {
// API key auth: resolveBodyIds handles resolution + permission check
const resolved = await resolveBodyIds(storageInput, authentication.environmentPermissions, "POST");
const resolved = await resolveBodyIds(storageInput, authentication.workspacePermissions, "POST");
if (!resolved.ok) return { response: resolved.response };
storageInput = resolved.body;
} else if (storageInput.workspaceId && !storageInput.environmentId) {
// Session auth with workspaceId only: resolve environmentId
if (typeof storageInput.workspaceId !== "string") {
return { response: responses.badRequestResponse("workspaceId must be a string") };
}
const envId = await getProductionEnvironmentIdByWorkspaceId(storageInput.workspaceId);
if (!envId) {
return { response: responses.notFoundResponse("Workspace", storageInput.workspaceId) };
}
storageInput = { ...storageInput, environmentId: envId };
} else if (storageInput.environmentId && !storageInput.workspaceId) {
// Session auth with environmentId only (current UI): resolve workspaceId
if (typeof storageInput.environmentId !== "string") {
return { response: responses.badRequestResponse("environmentId must be a string") };
}
try {
const wsId = await getWorkspaceIdFromEnvironmentId(storageInput.environmentId);
storageInput = { ...storageInput, workspaceId: wsId };
} catch {
return { response: responses.notFoundResponse("Environment", storageInput.environmentId) };
}
} else if (!storageInput.workspaceId) {
return { response: responses.badRequestResponse("workspaceId must be provided") };
}
const parsedInputResult = ZUploadPublicFileRequest.safeParse(storageInput);
@@ -76,9 +53,9 @@ export const POST = withV1ApiWrapper({
};
}
const { fileName, fileType, environmentId, workspaceId } = parsedInputResult.data;
const { fileName, fileType, workspaceId } = parsedInputResult.data;
const authResponse = await checkAuth(authentication, environmentId);
const authResponse = await checkAuth(authentication, workspaceId);
if (authResponse) {
return {
response: authResponse,
@@ -26,14 +26,14 @@ vi.mock("@formbricks/logger", () => ({
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
workspaceId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
@@ -41,7 +41,7 @@ const mockDeletedSurveyAppPrivateSegment = {
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
workspaceId,
type: "link",
segment: null,
triggers: [],
@@ -30,7 +30,7 @@ const fetchAndAuthorizeSurvey = async (
if (!survey) {
return { error: responses.notFoundResponse("Survey", surveyId) };
}
if (!hasPermission(authentication.environmentPermissions, survey.workspaceId, requiredPermission)) {
if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
@@ -24,7 +24,7 @@ export const GET = withV1ApiWrapper({
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.workspaceId, "GET")) {
if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
@@ -28,8 +28,8 @@ vi.mock("react", async () => {
};
});
const environmentId1 = "env1";
const environmentId2 = "env2";
const workspaceId1 = "env1";
const workspaceId2 = "env2";
const surveyId1 = "survey1";
const surveyId2 = "survey2";
const surveyId3 = "survey3";
@@ -38,19 +38,19 @@ type PrismaSurvey = Awaited<ReturnType<typeof prisma.survey.findMany>>[number];
const mockSurveyPrisma1 = {
id: surveyId1,
environmentId: environmentId1,
workspaceId: workspaceId1,
name: "Survey 1",
updatedAt: new Date(),
} as unknown as PrismaSurvey;
const mockSurveyPrisma2 = {
id: surveyId2,
environmentId: environmentId1,
workspaceId: workspaceId1,
name: "Survey 2",
updatedAt: new Date(),
} as unknown as PrismaSurvey;
const mockSurveyPrisma3 = {
id: surveyId3,
environmentId: environmentId2,
workspaceId: workspaceId2,
name: "Survey 3",
updatedAt: new Date(),
} as unknown as PrismaSurvey;
@@ -85,20 +85,20 @@ describe("getSurveys (Management API)", () => {
vi.resetAllMocks();
});
test("should return surveys for a single environment ID with limit and offset", async () => {
test("should return surveys for a single workspace ID with limit and offset", async () => {
const limit = 1;
const offset = 1;
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]);
const surveys = await getSurveys([environmentId1], limit, offset);
const surveys = await getSurveys([workspaceId1], limit, offset);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1], expect.any(Object)],
[[workspaceId1], expect.any(Object)],
[limit, expect.any(Object)],
[offset, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { workspaceId: { in: [environmentId1] } },
where: { workspaceId: { in: [workspaceId1] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: limit,
@@ -109,22 +109,22 @@ describe("getSurveys (Management API)", () => {
expect(surveys).toEqual([mockSurveyTransformed2]);
});
test("should return surveys for multiple environment IDs without limit and offset", async () => {
test("should return surveys for multiple workspace IDs without limit and offset", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
mockSurveyPrisma1,
mockSurveyPrisma2,
mockSurveyPrisma3,
]);
const surveys = await getSurveys([environmentId1, environmentId2]);
const surveys = await getSurveys([workspaceId1, workspaceId2]);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1, environmentId2], expect.any(Object)],
[[workspaceId1, workspaceId2], expect.any(Object)],
[undefined, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { workspaceId: { in: [environmentId1, environmentId2] } },
where: { workspaceId: { in: [workspaceId1, workspaceId2] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: undefined,
@@ -137,7 +137,7 @@ describe("getSurveys (Management API)", () => {
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const surveys = await getSurveys([environmentId1]);
const surveys = await getSurveys([workspaceId1]);
expect(prisma.survey.findMany).toHaveBeenCalled();
expect(transformPrismaSurvey).not.toHaveBeenCalled();
@@ -151,7 +151,7 @@ describe("getSurveys (Management API)", () => {
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError);
await expect(getSurveys([workspaceId1])).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
});
@@ -159,7 +159,7 @@ describe("getSurveys (Management API)", () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys([environmentId1])).rejects.toThrow(genericError);
await expect(getSurveys([workspaceId1])).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
});
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import {
TSurvey,
TSurveyCreateInputWithWorkspaceId,
@@ -88,7 +89,7 @@ const mockLanguage: TSurveyCreateInputWithWorkspaceId["languages"][number] = {
const baseSurveyData: TSurveyCreateInputWithWorkspaceId = {
name: "Test Survey",
environmentId: "test-env",
workspaceId: "mockWorkspaceId",
questions: [
{
id: "q1",
@@ -333,7 +334,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -345,7 +346,7 @@ describe("checkFeaturePermissions", () => {
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
const result = await checkFeaturePermissions(surveyData as any, mockOrganization);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
@@ -364,7 +365,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -384,7 +385,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -397,7 +398,7 @@ describe("checkFeaturePermissions", () => {
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
const result = await checkFeaturePermissions(surveyData as any, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});
@@ -413,7 +414,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -433,7 +434,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -446,7 +447,7 @@ describe("checkFeaturePermissions", () => {
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
const result = await checkFeaturePermissions(surveyData as any, mockOrganization, oldSurvey);
expect(result).toBeNull();
});
@@ -461,7 +462,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -473,7 +474,7 @@ describe("checkFeaturePermissions", () => {
},
],
};
const result = await checkFeaturePermissions(surveyData, mockOrganization);
const result = await checkFeaturePermissions(surveyData as any, mockOrganization);
expect(result).toBeNull();
});
@@ -488,7 +489,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: true,
@@ -508,7 +509,7 @@ describe("checkFeaturePermissions", () => {
elements: [
{
id: "cta1",
type: TSurveyQuestionTypeEnum.CTA,
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonExternal: false,
@@ -521,7 +522,7 @@ describe("checkFeaturePermissions", () => {
],
endings: [],
} as unknown as TSurvey;
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
const result = await checkFeaturePermissions(surveyData as any, mockOrganization, oldSurvey);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
});

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