mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-31 01:40:32 -05:00
Compare commits
6 Commits
feat/api-f
...
fix/naviga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876ce99c89 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 |
@@ -185,10 +185,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Public unauthenticated IP-based rate limits can be handled by an edge provider.
|
||||
# Supported values: none, cloudflare, cloudarmor, envoy
|
||||
# EDGE_RATE_LIMIT_PROVIDER=none
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
|
||||
@@ -75,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
HotelIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
@@ -16,28 +21,40 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
getOrganizationsForSwitcherAction,
|
||||
getProjectsForSwitcherAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -49,8 +66,30 @@ interface NavigationProps {
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
if (pathname.includes("/settings/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||
if (pathname.includes("/(account)/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
@@ -60,6 +99,10 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isAccessControlAllowed,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -69,7 +112,8 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -153,6 +197,143 @@ export const MainNavigation = ({
|
||||
},
|
||||
];
|
||||
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${environment.id}/workspace/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${environment.id}/workspace/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${environment.id}/workspace/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${environment.id}/workspace/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${environment.id}/workspace/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${environment.id}/workspace/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
const organizationSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${environment.id}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environment.id}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${environment.id}/settings/domain`,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environment.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingProjects(true);
|
||||
setWorkspaceLoadError(null);
|
||||
getProjectsForSwitcherAction({ organizationId: organization.id }).then((result) => {
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isOrganizationDropdownOpen ||
|
||||
organizations.length > 0 ||
|
||||
isLoadingOrganizations ||
|
||||
organizationLoadError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingOrganizations(true);
|
||||
setOrganizationLoadError(null);
|
||||
getOrganizationsForSwitcherAction({ organizationId: organization.id }).then((result) => {
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
setOrganizationLoadError(
|
||||
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||
);
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
organizationLoadError,
|
||||
organization.id,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
@@ -184,6 +365,71 @@ export const MainNavigation = ({
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === project.id) return;
|
||||
startTransition(() => {
|
||||
router.push(`/workspaces/${projectId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === organization.id) return;
|
||||
startTransition(() => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSettingNavigation = (href: string) => {
|
||||
startTransition(() => {
|
||||
router.push(href);
|
||||
});
|
||||
};
|
||||
|
||||
const handleProjectCreate = () => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
setOpenProjectLimitModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenCreateProjectModal(true);
|
||||
};
|
||||
|
||||
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||
if (isFormbricksCloud) {
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenProjectLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: isLicenseActive
|
||||
? `/environments/${environment.id}/settings/enterprise`
|
||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenProjectLimitModal(false),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const switcherTriggerClasses = cn(
|
||||
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus:outline-none",
|
||||
isCollapsed ? "flex items-center justify-center" : ""
|
||||
);
|
||||
|
||||
const switcherIconClasses =
|
||||
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
|
||||
return (
|
||||
<>
|
||||
{project && (
|
||||
@@ -263,35 +509,215 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3",
|
||||
isCollapsed && "justify-center"
|
||||
)}>
|
||||
<span className={switcherIconClasses}>
|
||||
<HotelIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && workspaceLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{workspaceLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setWorkspaceLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !workspaceLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === project.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
{proj.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleProjectCreate}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingNavigation(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="organizationDropdownTriggerSidebar"
|
||||
className={switcherTriggerClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3",
|
||||
isCollapsed && "justify-center"
|
||||
)}>
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && organizationLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !organizationLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === organization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.organization_settings")}
|
||||
</div>
|
||||
{organizationSettings.map((setting) => {
|
||||
if (setting.hidden) return null;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingNavigation(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
"flex w-full cursor-pointer items-center gap-3",
|
||||
isCollapsed && "justify-center"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div
|
||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
/>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -303,8 +729,6 @@ export const MainNavigation = ({
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
@@ -318,7 +742,6 @@ export const MainNavigation = ({
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
@@ -341,6 +764,28 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
{openProjectLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openProjectLimitModal}
|
||||
setOpen={setOpenProjectLimitModal}
|
||||
buttons={projectLimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
organizationId={organization.id}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
{openCreateOrganizationModal && (
|
||||
<CreateOrganizationModal
|
||||
open={openCreateOrganizationModal}
|
||||
setOpen={setOpenCreateOrganizationModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -54,6 +55,25 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -4,11 +4,6 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import {
|
||||
applyPublicIpRateLimit,
|
||||
publicEdgeRateLimitPolicies,
|
||||
} from "@/modules/core/rate-limit/public-edge-rate-limit";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -29,15 +24,6 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
try {
|
||||
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientDisplays, rateLimitConfigs.api.client);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
|
||||
@@ -14,11 +14,6 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import {
|
||||
applyPublicIpRateLimit,
|
||||
publicEdgeRateLimitPolicies,
|
||||
} from "@/modules/core/rate-limit/public-edge-rate-limit";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -41,15 +36,6 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
try {
|
||||
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.v2ClientResponses, rateLimitConfigs.api.client);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
|
||||
@@ -12,10 +12,6 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
|
||||
UNKNOWN_DATA: "unknown",
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
@@ -76,13 +72,10 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
|
||||
applyPublicIpRateLimitForRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
@@ -122,7 +115,6 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
@@ -139,13 +131,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -193,13 +183,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -241,13 +229,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on thrown error", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -299,13 +285,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -349,20 +333,17 @@ describe("withV1ApiWrapper", () => {
|
||||
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -385,13 +366,10 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyPublicIpRateLimitForRoute } = await import(
|
||||
"@/modules/core/rate-limit/public-edge-rate-limit"
|
||||
);
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
@@ -400,7 +378,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyPublicIpRateLimitForRoute).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
@@ -418,17 +396,11 @@ describe("withV1ApiWrapper", () => {
|
||||
auditLog: undefined,
|
||||
authentication: null,
|
||||
});
|
||||
expect(applyPublicIpRateLimitForRoute).toHaveBeenCalledWith(
|
||||
"/api/v1/client/displays",
|
||||
"GET",
|
||||
expect.objectContaining({ max: 100 })
|
||||
);
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -450,9 +422,8 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { getServerSession } = await import("next-auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -484,9 +455,8 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -511,13 +481,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { applyPublicIpRateLimitForRoute } from "@/modules/core/rate-limit/public-edge-rate-limit";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
@@ -55,22 +54,14 @@ enum ApiV1RouteTypeEnum {
|
||||
/**
|
||||
* Apply client-side API rate limiting (IP-based)
|
||||
*/
|
||||
const applyClientRateLimit = async (
|
||||
req: NextRequest,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
): Promise<void> => {
|
||||
await applyPublicIpRateLimitForRoute(
|
||||
req.nextUrl.pathname,
|
||||
req.method,
|
||||
customRateLimitConfig ?? rateLimitConfigs.api.client
|
||||
);
|
||||
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
req: NextRequest,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
@@ -90,7 +81,7 @@ const handleRateLimiting = async (
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
await applyClientRateLimit(req, customRateLimitConfig);
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error instanceof Error ? error.message : "Rate limit exceeded");
|
||||
@@ -314,12 +305,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,6 @@ describe("endpoint-validator", () => {
|
||||
isClientSideApi: true,
|
||||
isRateLimited: false,
|
||||
});
|
||||
expect(isClientSideApiRoute("/api/v1/client/og-image")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for non-client-side API routes", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ export enum AuthenticationMethod {
|
||||
|
||||
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
|
||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
||||
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(url)) return { isClientSideApi: true, isRateLimited: false };
|
||||
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
|
||||
|
||||
const regex = /^\/api\/v\d+\/client\//;
|
||||
return { isClientSideApi: regex.test(url), isRateLimited: true };
|
||||
|
||||
@@ -188,6 +188,7 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
@@ -258,6 +259,7 @@ checksums:
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -299,6 +301,7 @@ checksums:
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { env } from "./env";
|
||||
|
||||
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
|
||||
export const EDGE_RATE_LIMIT_PROVIDER = env.EDGE_RATE_LIMIT_PROVIDER ?? "none";
|
||||
|
||||
export const IS_PRODUCTION = env.NODE_ENV === "production";
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ export const env = createEnv({
|
||||
E2E_TESTING: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
EDGE_RATE_LIMIT_PROVIDER: z.enum(["none", "cloudflare", "cloudarmor", "envoy"]).optional(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
@@ -148,7 +147,6 @@ export const env = createEnv({
|
||||
E2E_TESTING: process.env.E2E_TESTING,
|
||||
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
|
||||
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
|
||||
EDGE_RATE_LIMIT_PROVIDER: process.env.EDGE_RATE_LIMIT_PROVIDER,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
|
||||
@@ -148,6 +148,8 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"change_organization": "Change organization",
|
||||
"change_workspace": "Change workspace",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
@@ -215,6 +217,7 @@
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
@@ -285,6 +288,7 @@
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership": "Membership",
|
||||
"membership_not_found": "Membership not found",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
@@ -326,6 +330,7 @@
|
||||
"organization_id": "Organization ID",
|
||||
"organization_settings": "Organization settings",
|
||||
"other": "Other",
|
||||
"other_filters": "Other Filters",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copia {copyNumber})",
|
||||
"e_commerce": "Comercio electrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership": "Membresía",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadatos",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID de organización",
|
||||
"organization_settings": "Ajustes de la organización",
|
||||
"other": "Otro",
|
||||
"other_filters": "Otros Filtros",
|
||||
"others": "Otros",
|
||||
"overlay_color": "Color de superposición",
|
||||
"overview": "Resumen",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership": "Adhésion",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"meta": "Méta",
|
||||
"metadata": "Métadonnées",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"other": "Autre",
|
||||
"other_filters": "Autres filtres",
|
||||
"others": "Autres",
|
||||
"overlay_color": "Couleur de superposition",
|
||||
"overview": "Aperçu",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "({copyNumber}. másolat)",
|
||||
"e_commerce": "E-kereskedelem",
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Tagok és csapatok",
|
||||
"membership": "Tagság",
|
||||
"membership_not_found": "A tagság nem található",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metaadatok",
|
||||
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Szervezetazonosító",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"others": "Mások",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(コピー {copyNumber})",
|
||||
"e_commerce": "Eコマース",
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership": "メンバーシップ",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"meta": "メタ",
|
||||
"metadata": "メタデータ",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "組織ID",
|
||||
"organization_settings": "組織設定",
|
||||
"other": "その他",
|
||||
"other_filters": "その他のフィルター",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(kopie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership": "Lidmaatschap",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metagegevens",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisatie-ID",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"other": "Ander",
|
||||
"other_filters": "Overige filters",
|
||||
"others": "Anderen",
|
||||
"overlay_color": "Overlaykleur",
|
||||
"overview": "Overzicht",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membros e equipes",
|
||||
"membership": "Associação",
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"other": "outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão Geral",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membros e equipas",
|
||||
"membership": "Subscrição",
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"other": "Outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão geral",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Membri și echipe",
|
||||
"membership": "Abonament",
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadate",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID Organizație",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"other": "Altele",
|
||||
"other_filters": "Alte Filtre",
|
||||
"others": "Altele",
|
||||
"overlay_color": "Culoare overlay",
|
||||
"overview": "Prezentare generală",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(копия {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Участники и команды",
|
||||
"membership": "Членство",
|
||||
"membership_not_found": "Участие не найдено",
|
||||
"meta": "Мета",
|
||||
"metadata": "Метаданные",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "ID организации",
|
||||
"organization_settings": "Настройки организации",
|
||||
"other": "Другое",
|
||||
"other_filters": "Другие фильтры",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(kopia {copyNumber})",
|
||||
"e_commerce": "E-handel",
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "Medlemmar och team",
|
||||
"membership": "Medlemskap",
|
||||
"membership_not_found": "Medlemskap hittades inte",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"other": "Annat",
|
||||
"other_filters": "Andra filter",
|
||||
"others": "Andra",
|
||||
"overlay_color": "Overlay-färg",
|
||||
"overview": "Översikt",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(副本 {copyNumber})",
|
||||
"e_commerce": "电子商务",
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership": "会员资格",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"meta": "元数据",
|
||||
"metadata": "元数据",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "组织 ID",
|
||||
"organization_settings": "组织 设置",
|
||||
"other": "其他",
|
||||
"other_filters": "其他筛选条件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"duplicate_copy_number": "(複製 {copyNumber})",
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
@@ -285,6 +286,7 @@
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership": "會員資格",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"meta": "Meta",
|
||||
"metadata": "元數據",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
@@ -326,6 +328,7 @@
|
||||
"organization_id": "組織 ID",
|
||||
"organization_settings": "組織設定",
|
||||
"other": "其他",
|
||||
"other_filters": "其他篩選條件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
|
||||
@@ -3,14 +3,11 @@ import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import {
|
||||
applyPublicIpRateLimit,
|
||||
publicEdgeRateLimitPolicies,
|
||||
} from "@/modules/core/rate-limit/public-edge-rate-limit";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { getUserByEmail } from "./user";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
// Mock encryption utilities
|
||||
@@ -22,48 +19,11 @@ vi.mock("@/lib/encryption", () => ({
|
||||
// Mock JWT
|
||||
vi.mock("@/lib/jwt");
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/public-edge-rate-limit", () => ({
|
||||
applyPublicIpRateLimit: vi.fn(),
|
||||
publicEdgeRateLimitPolicies: {
|
||||
authLogin: "auth.login",
|
||||
authVerifyEmail: "auth.verify_email",
|
||||
},
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./user", () => ({
|
||||
getUserByEmail: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
updateUserLastLoginAt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./brevo", () => ({
|
||||
createBrevoCustomer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/sso/lib/providers", () => ({
|
||||
getSSOProviders: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/sso/lib/sso-handlers", () => ({
|
||||
handleSsoCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
|
||||
UNKNOWN_DATA: "unknown",
|
||||
}));
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./utils")>();
|
||||
return {
|
||||
...actual,
|
||||
shouldLogAuthFailure: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
auth: {
|
||||
@@ -73,22 +33,26 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
}));
|
||||
// Mock constants that this test needs while preserving untouched exports.
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
};
|
||||
});
|
||||
|
||||
// Mock next/headers
|
||||
vi.mock("next/headers", () => ({
|
||||
@@ -150,7 +114,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
@@ -161,7 +125,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if user has no password stored", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
@@ -176,7 +140,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if password verification fails", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -191,7 +155,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should successfully login when credentials are valid", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const fakeUser = {
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -214,7 +178,7 @@ describe("authOptions", () => {
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before credential validation", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -227,15 +191,12 @@ describe("authOptions", () => {
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
|
||||
publicEdgeRateLimitPolicies.authLogin,
|
||||
rateLimitConfigs.auth.login
|
||||
);
|
||||
expect(applyPublicIpRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
|
||||
});
|
||||
|
||||
test("should block login when rate limit exceeded", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
|
||||
@@ -250,7 +211,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -263,7 +224,7 @@ describe("authOptions", () => {
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(publicEdgeRateLimitPolicies.authLogin, {
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 900,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "auth:login",
|
||||
@@ -273,7 +234,7 @@ describe("authOptions", () => {
|
||||
|
||||
describe("Two-Factor Backup Code login", () => {
|
||||
test("should throw error if backup codes are missing", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -302,7 +263,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or user not found", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const credentials = { token: "badtoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
@@ -312,20 +273,17 @@ describe("authOptions", () => {
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
|
||||
|
||||
expect(applyPublicIpRateLimit).toHaveBeenCalledWith(
|
||||
publicEdgeRateLimitPolicies.authVerifyEmail,
|
||||
rateLimitConfigs.auth.verifyEmail
|
||||
);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
});
|
||||
|
||||
test("should block verification when rate limit exceeded", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockRejectedValue(
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
|
||||
@@ -344,7 +302,7 @@ describe("authOptions", () => {
|
||||
describe("Callbacks", () => {
|
||||
describe("jwt callback", () => {
|
||||
test("should add profile information to token if user is found", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
locale: mockUser.locale,
|
||||
email: mockUser.email,
|
||||
@@ -363,7 +321,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should return token unchanged if no existing user is found", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
|
||||
|
||||
const token = { email: "nonexistent@example.com" };
|
||||
if (!authOptions.callbacks?.jwt) {
|
||||
@@ -408,7 +366,7 @@ describe("authOptions", () => {
|
||||
const credentialsProvider = getProviderById("credentials");
|
||||
|
||||
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -426,7 +384,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if two factor secret is missing", async () => {
|
||||
vi.mocked(applyPublicIpRateLimit).mockResolvedValue("app");
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
|
||||
@@ -23,10 +23,7 @@ import {
|
||||
shouldLogAuthFailure,
|
||||
verifyPassword,
|
||||
} from "@/modules/auth/lib/utils";
|
||||
import {
|
||||
applyPublicIpRateLimit,
|
||||
publicEdgeRateLimitPolicies,
|
||||
} from "@/modules/core/rate-limit/public-edge-rate-limit";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
|
||||
@@ -58,7 +55,7 @@ export const authOptions: NextAuthOptions = {
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyPublicIpRateLimit(publicEdgeRateLimitPolicies.authLogin, rateLimitConfigs.auth.login);
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.login);
|
||||
|
||||
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
|
||||
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
|
||||
@@ -248,10 +245,7 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyPublicIpRateLimit(
|
||||
publicEdgeRateLimitPolicies.authVerifyEmail,
|
||||
rateLimitConfigs.auth.verifyEmail
|
||||
);
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
|
||||
|
||||
// For token verification, we can't rate limit effectively by token (single-use)
|
||||
// So we use a generic identifier for token abuse attempts
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { applyIPRateLimit } from "./helpers";
|
||||
import {
|
||||
applyPublicIpRateLimit,
|
||||
applyPublicIpRateLimitForRoute,
|
||||
getEdgeRateLimitProvider,
|
||||
getPublicEdgeRateLimitPolicyId,
|
||||
isPublicEdgeRateLimitManaged,
|
||||
publicEdgeRateLimitPolicies,
|
||||
} from "./public-edge-rate-limit";
|
||||
|
||||
vi.mock("./helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockConfig = {
|
||||
interval: 60,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "api:client",
|
||||
};
|
||||
|
||||
describe("public-edge-rate-limit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getEdgeRateLimitProvider", () => {
|
||||
test("falls back to none for unknown providers", () => {
|
||||
expect(getEdgeRateLimitProvider(undefined)).toBe("none");
|
||||
expect(getEdgeRateLimitProvider("unknown")).toBe("none");
|
||||
});
|
||||
|
||||
test("accepts configured providers", () => {
|
||||
expect(getEdgeRateLimitProvider("cloudflare")).toBe("cloudflare");
|
||||
expect(getEdgeRateLimitProvider("cloudarmor")).toBe("cloudarmor");
|
||||
expect(getEdgeRateLimitProvider("envoy")).toBe("envoy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicEdgeRateLimitPolicyId", () => {
|
||||
test("classifies auth callback routes", () => {
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/credentials", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.authLogin
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/auth/callback/token", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.authVerifyEmail
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies v1 client routes", () => {
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/environment", "GET")).toBe(
|
||||
publicEdgeRateLimitPolicies.v1ClientDefault
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/env_123/storage", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.v1ClientStorageUpload
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og", "GET")).toBeNull();
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og/image", "GET")).toBeNull();
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v1/client/og-image", "GET")).toBe(
|
||||
publicEdgeRateLimitPolicies.v1ClientDefault
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies v2 public write routes", () => {
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.v2ClientResponses
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/responses/resp_123", "PUT")).toBe(
|
||||
publicEdgeRateLimitPolicies.v2ClientResponses
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/displays", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.v2ClientDisplays
|
||||
);
|
||||
expect(getPublicEdgeRateLimitPolicyId("/api/v2/client/env_123/storage", "POST")).toBe(
|
||||
publicEdgeRateLimitPolicies.v2ClientStorageUpload
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicEdgeRateLimitManaged", () => {
|
||||
test("manages public policies on cloudflare and cloudarmor only", () => {
|
||||
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudflare")).toBe(true);
|
||||
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "cloudarmor")).toBe(true);
|
||||
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "none")).toBe(false);
|
||||
expect(isPublicEdgeRateLimitManaged(publicEdgeRateLimitPolicies.authLogin, "envoy")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPublicIpRateLimit", () => {
|
||||
test("uses app rate limiting when no edge provider manages the policy", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const source = await applyPublicIpRateLimit(
|
||||
publicEdgeRateLimitPolicies.v2ClientResponses,
|
||||
mockConfig,
|
||||
"none"
|
||||
);
|
||||
|
||||
expect(source).toBe("app");
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
test("skips app rate limiting when the edge provider manages the policy", async () => {
|
||||
const source = await applyPublicIpRateLimit(
|
||||
publicEdgeRateLimitPolicies.v2ClientResponses,
|
||||
mockConfig,
|
||||
"cloudflare"
|
||||
);
|
||||
|
||||
expect(source).toBe("edge");
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPublicIpRateLimitForRoute", () => {
|
||||
test("uses the route classifier for managed public routes", async () => {
|
||||
const source = await applyPublicIpRateLimitForRoute(
|
||||
"/api/v2/client/env_123/displays",
|
||||
"POST",
|
||||
mockConfig,
|
||||
"cloudarmor"
|
||||
);
|
||||
|
||||
expect(source).toBe("edge");
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to app rate limiting for unmanaged routes", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const source = await applyPublicIpRateLimitForRoute(
|
||||
"/api/v1/client/env_123/environment",
|
||||
"GET",
|
||||
mockConfig,
|
||||
"envoy"
|
||||
);
|
||||
|
||||
expect(source).toBe("app");
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { EDGE_RATE_LIMIT_PROVIDER } from "@/lib/constants";
|
||||
import { applyIPRateLimit } from "./helpers";
|
||||
import { TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
export const publicEdgeRateLimitPolicies = {
|
||||
authLogin: "auth.login",
|
||||
authVerifyEmail: "auth.verify_email",
|
||||
v1ClientDefault: "client.v1.default",
|
||||
v1ClientStorageUpload: "client.storage.upload.v1",
|
||||
v2ClientResponses: "client.responses.v2",
|
||||
v2ClientDisplays: "client.displays.v2",
|
||||
v2ClientStorageUpload: "client.storage.upload.v2",
|
||||
} as const;
|
||||
|
||||
export type TPublicEdgeRateLimitPolicyId =
|
||||
(typeof publicEdgeRateLimitPolicies)[keyof typeof publicEdgeRateLimitPolicies];
|
||||
|
||||
export type TEdgeRateLimitProvider = "none" | "cloudflare" | "cloudarmor" | "envoy";
|
||||
|
||||
const managedPublicEdgePolicies = Object.values(
|
||||
publicEdgeRateLimitPolicies
|
||||
) as TPublicEdgeRateLimitPolicyId[];
|
||||
|
||||
const managedPublicEdgePoliciesByProvider: Record<
|
||||
TEdgeRateLimitProvider,
|
||||
readonly TPublicEdgeRateLimitPolicyId[]
|
||||
> = {
|
||||
none: [],
|
||||
cloudflare: managedPublicEdgePolicies,
|
||||
cloudarmor: managedPublicEdgePolicies,
|
||||
envoy: [],
|
||||
};
|
||||
|
||||
const normalizeEdgeRateLimitProvider = (provider: string | undefined): TEdgeRateLimitProvider => {
|
||||
switch (provider) {
|
||||
case "cloudflare":
|
||||
case "cloudarmor":
|
||||
case "envoy":
|
||||
return provider;
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
if (pathname.length > 1 && pathname.endsWith("/")) {
|
||||
return pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
};
|
||||
|
||||
export const getEdgeRateLimitProvider = (
|
||||
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
|
||||
): TEdgeRateLimitProvider => normalizeEdgeRateLimitProvider(provider);
|
||||
|
||||
export const getPublicEdgeRateLimitPolicyId = (
|
||||
pathname: string,
|
||||
method: string
|
||||
): TPublicEdgeRateLimitPolicyId | null => {
|
||||
const normalizedPathname = normalizePathname(pathname);
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
|
||||
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/credentials") {
|
||||
return publicEdgeRateLimitPolicies.authLogin;
|
||||
}
|
||||
|
||||
if (normalizedMethod === "POST" && normalizedPathname === "/api/auth/callback/token") {
|
||||
return publicEdgeRateLimitPolicies.authVerifyEmail;
|
||||
}
|
||||
|
||||
if (/^\/api\/v1\/client\/og(?:\/.*)?$/.test(normalizedPathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\/api\/v1\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
|
||||
return publicEdgeRateLimitPolicies.v1ClientStorageUpload;
|
||||
}
|
||||
|
||||
if (/^\/api\/v2\/client\/[^/]+\/storage$/.test(normalizedPathname) && normalizedMethod === "POST") {
|
||||
return publicEdgeRateLimitPolicies.v2ClientStorageUpload;
|
||||
}
|
||||
|
||||
if (
|
||||
/^\/api\/v2\/client\/[^/]+\/responses(?:\/[^/]+)?$/.test(normalizedPathname) &&
|
||||
(normalizedMethod === "POST" || normalizedMethod === "PUT")
|
||||
) {
|
||||
return publicEdgeRateLimitPolicies.v2ClientResponses;
|
||||
}
|
||||
|
||||
if (/^\/api\/v2\/client\/[^/]+\/displays$/.test(normalizedPathname) && normalizedMethod === "POST") {
|
||||
return publicEdgeRateLimitPolicies.v2ClientDisplays;
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith("/api/v1/client/")) {
|
||||
return publicEdgeRateLimitPolicies.v1ClientDefault;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isPublicEdgeRateLimitManaged = (
|
||||
policyId: TPublicEdgeRateLimitPolicyId,
|
||||
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
|
||||
): boolean => managedPublicEdgePoliciesByProvider[getEdgeRateLimitProvider(provider)].includes(policyId);
|
||||
|
||||
export const applyPublicIpRateLimit = async (
|
||||
policyId: TPublicEdgeRateLimitPolicyId,
|
||||
config: TRateLimitConfig,
|
||||
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
|
||||
): Promise<"app" | "edge"> => {
|
||||
if (isPublicEdgeRateLimitManaged(policyId, provider)) {
|
||||
return "edge";
|
||||
}
|
||||
|
||||
await applyIPRateLimit(config);
|
||||
|
||||
return "app";
|
||||
};
|
||||
|
||||
export const applyPublicIpRateLimitForRoute = async (
|
||||
pathname: string,
|
||||
method: string,
|
||||
config: TRateLimitConfig,
|
||||
provider: string | undefined = EDGE_RATE_LIMIT_PROVIDER
|
||||
): Promise<"app" | "edge"> => {
|
||||
const policyId = getPublicEdgeRateLimitPolicyId(pathname, method);
|
||||
|
||||
if (!policyId) {
|
||||
await applyIPRateLimit(config);
|
||||
return "app";
|
||||
}
|
||||
|
||||
return await applyPublicIpRateLimit(policyId, config, provider);
|
||||
};
|
||||
@@ -1313,11 +1313,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
hobbySubscriptions.map(({ subscription }) =>
|
||||
client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
})
|
||||
)
|
||||
hobbySubscriptions.map(async ({ subscription }) => {
|
||||
try {
|
||||
await client.subscriptions.cancel(subscription.id, {
|
||||
prorate: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Stripe.errors.StripeInvalidRequestError &&
|
||||
err.statusCode === 404 &&
|
||||
err.code === "resource_missing"
|
||||
) {
|
||||
logger.warn(
|
||||
{ subscriptionId: subscription.id, organizationId },
|
||||
"Subscription already deleted, skipping cancel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
|
||||
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, loading, className }))}
|
||||
disabled={loading}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
{...props}
|
||||
disabled={loading || disabled}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
|
||||
@@ -186,7 +186,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
EDGE_RATE_LIMIT_PROVIDER: "none",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
|
||||
@@ -73,7 +73,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
|
||||
@@ -83,7 +83,7 @@ function CTA({
|
||||
/>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative space-y-2" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{buttonExternal ? (
|
||||
@@ -95,7 +95,7 @@ function CTA({
|
||||
disabled={disabled}
|
||||
className="text-button font-button-weight flex items-center gap-2"
|
||||
variant={buttonVariant}
|
||||
size={"custom"}>
|
||||
size="custom">
|
||||
{buttonLabel}
|
||||
<SquareArrowOutUpRightIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -161,7 +161,7 @@ function DateElement({
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -292,7 +292,7 @@ function FileUpload({
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -112,7 +112,7 @@ function FormField({
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ function Matrix({
|
||||
/>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
|
||||
@@ -145,7 +145,7 @@ function DropdownVariant({
|
||||
searchPlaceholder,
|
||||
searchNoResultsText,
|
||||
}: Readonly<DropdownVariantProps>): React.JSX.Element {
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
const handleOptionToggle = (optionId: string): void => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
@@ -540,7 +540,7 @@ function MultiSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<DropdownVariant
|
||||
inputId={inputId}
|
||||
|
||||
@@ -172,7 +172,7 @@ function NPS({
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]" dir={dir}>
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
|
||||
@@ -79,7 +79,7 @@ function OpenText({
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} />
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -106,7 +106,7 @@ function PictureSelect({
|
||||
/>
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -223,7 +223,7 @@ function Ranking({
|
||||
/>
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
|
||||
@@ -407,7 +407,7 @@ function Rating({
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
|
||||
@@ -181,7 +181,7 @@ function SingleSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
@@ -278,7 +278,7 @@ function SingleSelect({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<RadioGroup
|
||||
name={inputId}
|
||||
|
||||
@@ -278,11 +278,12 @@ export function BlockConditional({
|
||||
if (hasValidationErrors) {
|
||||
setElementErrors(errorMap);
|
||||
|
||||
// Find the first element with an error and scroll to it
|
||||
// Find the first element with an error and scroll to its input area (not the headline)
|
||||
const firstErrorElementId = Object.keys(errorMap)[0];
|
||||
const form = elementFormRefs.current.get(firstErrorElementId);
|
||||
if (form) {
|
||||
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = form.querySelector("[data-element-input]") ?? form;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +291,8 @@ export function BlockConditional({
|
||||
// Also run legacy validation for elements not yet migrated to centralized validation
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm;
|
||||
scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TSurveyLanguage } from "./types";
|
||||
import { getTextContent } from "./validation";
|
||||
import { findLanguageCodesForDuplicateLabels, getTextContent } from "./validation";
|
||||
|
||||
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
|
||||
if (!surveyLanguages) return [];
|
||||
@@ -92,28 +92,5 @@ export const validateElementLabels = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findLanguageCodesForDuplicateLabels = (
|
||||
labels: TI18nString[],
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
): string[] => {
|
||||
const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled);
|
||||
const languageCodes = extractLanguageCodes(enabledLanguages);
|
||||
|
||||
const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes;
|
||||
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
duplicateLabels.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicateLabels);
|
||||
};
|
||||
// Re-export for backwards compatibility
|
||||
export { findLanguageCodesForDuplicateLabels };
|
||||
|
||||
@@ -228,7 +228,10 @@ export const findLanguageCodesForDuplicateLabels = (
|
||||
const duplicateLabels = new Set<string>();
|
||||
|
||||
for (const language of languagesToCheck) {
|
||||
const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean);
|
||||
const labelTexts = labels
|
||||
.map((label) => label[language])
|
||||
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
||||
.map((text) => text.trim());
|
||||
const uniqueLabels = new Set(labelTexts);
|
||||
|
||||
if (uniqueLabels.size !== labelTexts.length) {
|
||||
|
||||
@@ -146,7 +146,6 @@
|
||||
"E2E_TESTING",
|
||||
"EMAIL_AUTH_DISABLED",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"EDGE_RATE_LIMIT_PROVIDER",
|
||||
"ENCRYPTION_KEY",
|
||||
"ENTERPRISE_LICENSE_KEY",
|
||||
"ENVIRONMENT",
|
||||
|
||||
Reference in New Issue
Block a user