mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 19:39:00 -05:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80e1cc2411 | |||
| bd05387d99 | |||
| 9b4be60dd9 | |||
| bad3b7a771 | |||
| 007d99f6b8 | |||
| 03b7dfefe4 | |||
| 9178558ba1 | |||
| a65e6d9093 | |||
| 592d36542f | |||
| 5ec8218666 | |||
| e1a44817f2 | |||
| 7f5b2bf69d | |||
| 60e7c7e8ee | |||
| 7988d7775c | |||
| b7ede6c578 | |||
| 8204a5c652 | |||
| e823e10f9a | |||
| f5c3212b2c | |||
| 2d66fc6987 | |||
| 652970003d | |||
| a8b5e286b6 | |||
| 322f0be197 | |||
| 1a02f91afd | |||
| cc22ccb22d | |||
| 12763f0ef6 | |||
| d39e3ee638 | |||
| d85242a86b | |||
| ef53065abc | |||
| 805c1c6874 | |||
| 01687e8907 | |||
| 31d455002d | |||
| d96304d86d | |||
| 1064f68435 | |||
| 3d16e859c6 | |||
| af198c5632 | |||
| a43ed2b25c | |||
| 87bcad2b20 |
@@ -138,6 +138,31 @@ AZUREAD_CLIENT_ID=
|
||||
AZUREAD_CLIENT_SECRET=
|
||||
AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# AI_PROVIDER=gcp
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Vertex AI credentials
|
||||
# AI_GCP_PROJECT=
|
||||
# AI_GCP_LOCATION=
|
||||
# AI_GCP_CREDENTIALS_JSON=
|
||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Amazon Bedrock credentials
|
||||
# AI_AWS_REGION=
|
||||
# AI_AWS_ACCESS_KEY_ID=
|
||||
# AI_AWS_SECRET_ACCESS_KEY=
|
||||
# AI_AWS_SESSION_TOKEN=
|
||||
|
||||
# Azure AI / Microsoft Foundry credentials
|
||||
# AI_AZURE_BASE_URL=
|
||||
# AI_AZURE_RESOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_API_VERSION=v1
|
||||
|
||||
# OpenID Connect (OIDC) configuration
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
|
||||
+13
-1
@@ -1 +1,13 @@
|
||||
pnpm lint-staged
|
||||
#!/usr/bin/env sh
|
||||
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm lint-staged
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
npm exec --yes pnpm@10.32.1 lint-staged
|
||||
elif command -v corepack >/dev/null 2>&1; then
|
||||
corepack pnpm lint-staged
|
||||
else
|
||||
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
||||
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
||||
exit 127
|
||||
fi
|
||||
@@ -26,7 +26,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
||||
const isMembershipPending = membership?.role === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
@@ -45,6 +46,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,41 +2,59 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
FoldersIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, 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 { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
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 {
|
||||
@@ -48,8 +66,31 @@ 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 => {
|
||||
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
|
||||
if (accountSettingsPattern.test(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
@@ -59,6 +100,10 @@ export const MainNavigation = ({
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isAccessControlAllowed,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -68,7 +113,12 @@ 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 isMembershipPending = membershipRole === undefined;
|
||||
const disabledNavigationMessage = isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -105,6 +155,7 @@ export const MainNavigation = ({
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
@@ -114,15 +165,17 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname]
|
||||
[t, environment.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -145,6 +198,183 @@ 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 [hasInitializedProjects, setHasInitializedProjects] = 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 renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{error}</p>
|
||||
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setIsLoadingProjects(true);
|
||||
setWorkspaceLoadError(null);
|
||||
|
||||
try {
|
||||
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
|
||||
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"));
|
||||
}
|
||||
} catch (error) {
|
||||
const formattedError =
|
||||
typeof error === "object" && error !== null
|
||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
||||
: "";
|
||||
setWorkspaceLoadError(
|
||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingProjects(false);
|
||||
setHasInitializedProjects(true);
|
||||
}
|
||||
}, [organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setIsLoadingOrganizations(true);
|
||||
setOrganizationLoadError(null);
|
||||
|
||||
try {
|
||||
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
||||
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")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const formattedError =
|
||||
typeof error === "object" && error !== null
|
||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
||||
: "";
|
||||
setOrganizationLoadError(
|
||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingOrganizations(false);
|
||||
}
|
||||
}, [organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isOrganizationDropdownOpen ||
|
||||
organizations.length > 0 ||
|
||||
isLoadingOrganizations ||
|
||||
organizationLoadError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadOrganizations();
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
organizationLoadError,
|
||||
loadOrganizations,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
@@ -174,7 +404,79 @@ export const MainNavigation = ({
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
const mainNavigationLink = isBilling
|
||||
? getBillingFallbackPath(environment.id, isFormbricksCloud)
|
||||
: `/environments/${environment.id}/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 (!hasInitializedProjects || isLoadingProjects) {
|
||||
return;
|
||||
}
|
||||
|
||||
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-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
|
||||
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";
|
||||
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -214,24 +516,24 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
{!isBilling && (
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -255,38 +557,210 @@ 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}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<span className={switcherIconClasses}>
|
||||
<FoldersIcon 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} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_workspace")}
|
||||
</div>
|
||||
{(isLoadingProjects || isInitialProjectsLoading) && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects &&
|
||||
!isInitialProjectsLoading &&
|
||||
workspaceLoadError &&
|
||||
renderSwitcherError(
|
||||
workspaceLoadError,
|
||||
() => {
|
||||
setWorkspaceLoadError(null);
|
||||
setProjects([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!isLoadingProjects && !isInitialProjectsLoading && !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}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
||||
className={cn("flex w-full 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} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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 &&
|
||||
renderSwitcherError(
|
||||
organizationLoadError,
|
||||
() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
},
|
||||
t("common.try_again")
|
||||
)}
|
||||
{!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">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||
<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>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
@@ -295,8 +769,6 @@ export const MainNavigation = ({
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
@@ -310,7 +782,6 @@ export const MainNavigation = ({
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
@@ -333,6 +804,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 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface NavigationLinkProps {
|
||||
@@ -10,6 +11,8 @@ interface NavigationLinkProps {
|
||||
children: React.ReactNode;
|
||||
linkText: string;
|
||||
isTextVisible: boolean;
|
||||
disabled?: boolean;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export const NavigationLink = ({
|
||||
@@ -19,10 +22,34 @@ export const NavigationLink = ({
|
||||
children,
|
||||
linkText,
|
||||
isTextVisible = true,
|
||||
disabled = false,
|
||||
disabledMessage,
|
||||
}: NavigationLinkProps) => {
|
||||
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||
const inactiveClass =
|
||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
||||
const getColorClass = (baseClass: string) => {
|
||||
if (disabled) {
|
||||
return disabledClass;
|
||||
}
|
||||
|
||||
return cn(baseClass, isActive ? activeClass : inactiveClass);
|
||||
};
|
||||
|
||||
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
|
||||
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
|
||||
|
||||
const label = (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 flex transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{linkText}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -30,35 +57,37 @@ export const NavigationLink = ({
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
||||
isActive ? activeClass : inactiveClass
|
||||
)}>
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
</Link>
|
||||
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
||||
{disabled ? (
|
||||
<div className="flex items-center">{children}</div>
|
||||
) : (
|
||||
<Link href={href}>{children}</Link>
|
||||
)}
|
||||
</li>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||
isActive ? activeClass : inactiveClass
|
||||
)}>
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 flex transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{linkText}
|
||||
</span>
|
||||
</Link>
|
||||
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
||||
{disabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
{label}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{disabledMessage || linkText}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Link href={href} className="flex items-center">
|
||||
{children}
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -31,7 +31,8 @@ export const TopControlBar = ({
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
}: TopControlBarProps) => {
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const isMembershipPending = membershipRole === undefined;
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
@@ -49,6 +50,8 @@ export const TopControlBar = ({
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+36
-10
@@ -25,6 +25,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
@@ -35,6 +36,7 @@ interface OrganizationBreadcrumbProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isMember: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||
@@ -56,6 +58,7 @@ export const OrganizationBreadcrumb = ({
|
||||
isFormbricksCloud,
|
||||
isMember,
|
||||
isOwnerOrManager,
|
||||
isMembershipPending,
|
||||
}: OrganizationBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
@@ -142,7 +145,10 @@ export const OrganizationBreadcrumb = ({
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
disabled: isMembershipPending || !isOwnerOrManager,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
@@ -160,7 +166,11 @@ export const OrganizationBreadcrumb = ({
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
hidden: isFormbricksCloud,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -242,14 +252,30 @@ export const OrganizationBreadcrumb = ({
|
||||
|
||||
{organizationSettings.map((setting) => {
|
||||
return setting.hidden ? null : (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
hidden={setting.hidden}
|
||||
onClick={() => handleSettingChange(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<div key={setting.id}>
|
||||
{setting.disabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled="true"
|
||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||
{setting.label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{setting.disabledMessage}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
onClick={() => handleSettingChange(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ProjectAndOrgSwitchProps {
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isBilling: boolean;
|
||||
isMembershipPending: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -35,6 +37,8 @@ export const ProjectAndOrgSwitch = ({
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
isBilling,
|
||||
isMembershipPending,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
@@ -50,6 +54,7 @@ export const ProjectAndOrgSwitch = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
@@ -63,6 +68,8 @@ export const ProjectAndOrgSwitch = ({
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
)}
|
||||
{showEnvironmentBreadcrumb && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
@@ -33,6 +34,8 @@ interface ProjectBreadcrumbProps {
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
isBilling: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
@@ -56,6 +59,8 @@ export const ProjectBreadcrumb = ({
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
isBilling,
|
||||
isMembershipPending,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
@@ -134,6 +139,10 @@ export const ProjectBreadcrumb = ({
|
||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||
},
|
||||
];
|
||||
const areProjectSettingsDisabled = isMembershipPending || isBilling;
|
||||
const projectSettingsDisabledMessage = isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||
@@ -198,7 +207,7 @@ export const ProjectBreadcrumb = ({
|
||||
id="projectDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{projectName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||
@@ -211,7 +220,7 @@ export const ProjectBreadcrumb = ({
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<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} />
|
||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
@@ -247,7 +256,24 @@ export const ProjectBreadcrumb = ({
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
{isMembershipPending || !isOwnerOrManager ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled="true"
|
||||
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action")}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
@@ -264,13 +290,30 @@ export const ProjectBreadcrumb = ({
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<div key={setting.id}>
|
||||
{areProjectSettingsDisabled ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled="true"
|
||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||
{setting.label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{projectSettingsDisabledMessage}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -12,11 +13,7 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
|
||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isBilling) {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
} else {
|
||||
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
||||
}
|
||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||
|
||||
+2
-4
@@ -6,11 +6,9 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
|
||||
+5
-1
@@ -116,10 +116,14 @@ export const EditProfileDetailsForm = ({
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
await updateUserAction({
|
||||
const result = await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
import { getIsEmailUnique } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
|
||||
-37
@@ -1,42 +1,5 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
+13
-5
@@ -22,8 +22,9 @@ export const OrganizationSettingsNavbar = ({
|
||||
loading,
|
||||
}: OrganizationSettingsNavbarProps) => {
|
||||
const pathname = usePathname();
|
||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||
const isPricingDisabled = isMember;
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const isMembershipPending = membershipRole === undefined || loading;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigation = [
|
||||
@@ -45,7 +46,10 @@ export const OrganizationSettingsNavbar = ({
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
disabled: isMembershipPending || !isOwnerOrManager,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
@@ -58,14 +62,18 @@ export const OrganizationSettingsNavbar = ({
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
hidden: !isFormbricksCloud,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
hidden: isFormbricksCloud,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
];
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { updateOrganizationAISettingsAction } from "./actions";
|
||||
import { ZOrganizationAISettingsInput } from "./schemas";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isInstanceAIConfigured: vi.fn(),
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
deleteOrganization: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getTranslate: vi.fn(),
|
||||
updateOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
inputSchema: vi.fn(() => ({
|
||||
action: vi.fn((fn) => fn),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
deleteOrganization: mocks.deleteOrganization,
|
||||
getOrganization: mocks.getOrganization,
|
||||
updateOrganization: mocks.updateOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/service", () => ({
|
||||
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: mocks.getTranslate,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
||||
}));
|
||||
|
||||
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
||||
|
||||
describe("organization AI settings actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key
|
||||
);
|
||||
mocks.updateOrganization.mockResolvedValue({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("accepts AI toggle updates", () => {
|
||||
expect(
|
||||
ZOrganizationAISettingsInput.parse({
|
||||
isAISmartToolsEnabled: true,
|
||||
})
|
||||
).toEqual({
|
||||
isAISmartToolsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
||||
const ctx = {
|
||||
user: { id: "user_1", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
};
|
||||
const parsedInput = {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationAISettingsInput,
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
||||
expect(ctx.auditLoggingCtx).toMatchObject({
|
||||
organizationId,
|
||||
oldObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
newObject: {
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("propagates authorization failures so members cannot update AI settings", async () => {
|
||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
|
||||
await expect(
|
||||
updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_member", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await expect(
|
||||
updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
|
||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows enabling AI when the instance configuration is valid", async () => {
|
||||
await updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||
isAISmartToolsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: organizationId,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await updateOrganizationAISettingsAction({
|
||||
ctx: {
|
||||
user: { id: "user_owner", locale: "en-US" },
|
||||
auditLoggingCtx: {},
|
||||
},
|
||||
parsedInput: {
|
||||
organizationId,
|
||||
data: {
|
||||
isAISmartToolsEnabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||
isAISmartToolsEnabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
+78
-13
@@ -2,15 +2,18 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
|
||||
|
||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
||||
ctx,
|
||||
@@ -66,10 +69,53 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
|
||||
});
|
||||
type TOrganizationAISettings = Pick<
|
||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
||||
>;
|
||||
|
||||
type TResolvedOrganizationAISettings = {
|
||||
smartToolsEnabled: boolean;
|
||||
dataAnalysisEnabled: boolean;
|
||||
isEnablingAnyAISetting: boolean;
|
||||
};
|
||||
|
||||
const resolveOrganizationAISettings = ({
|
||||
data,
|
||||
organization,
|
||||
}: {
|
||||
data: z.infer<typeof ZOrganizationAISettingsInput>;
|
||||
organization: TOrganizationAISettings;
|
||||
}): TResolvedOrganizationAISettings => {
|
||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
||||
: organization.isAISmartToolsEnabled;
|
||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
||||
: organization.isAIDataAnalysisEnabled;
|
||||
|
||||
return {
|
||||
smartToolsEnabled,
|
||||
dataAnalysisEnabled,
|
||||
isEnablingAnyAISetting:
|
||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
||||
};
|
||||
};
|
||||
|
||||
const assertOrganizationAISettingsUpdateAllowed = ({
|
||||
isInstanceAIConfigured,
|
||||
resolvedSettings,
|
||||
t,
|
||||
}: {
|
||||
isInstanceAIConfigured: boolean;
|
||||
resolvedSettings: TResolvedOrganizationAISettings;
|
||||
t: Awaited<ReturnType<typeof getTranslate>>;
|
||||
}) => {
|
||||
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
|
||||
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
||||
}
|
||||
};
|
||||
|
||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
||||
@@ -83,17 +129,33 @@ export const updateOrganizationAISettingsAction = authenticatedActionClient
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
}) => {
|
||||
const t = await getTranslate(ctx.user.locale);
|
||||
const organization = await getOrganization(parsedInput.organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
|
||||
}
|
||||
|
||||
const resolvedSettings = resolveOrganizationAISettings({
|
||||
data: parsedInput.data,
|
||||
organization,
|
||||
});
|
||||
|
||||
assertOrganizationAISettingsUpdateAllowed({
|
||||
isInstanceAIConfigured: isInstanceAIConfigured(),
|
||||
resolvedSettings,
|
||||
t,
|
||||
});
|
||||
|
||||
return updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
}),
|
||||
schema: ZOrganizationAISettingsInput,
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
})
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -106,7 +168,10 @@ export const deleteOrganizationAction = authenticatedActionClient
|
||||
.action(
|
||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
if (!isMultiOrgEnabled) {
|
||||
const t = await getTranslate(ctx.user.locale);
|
||||
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
+45
-11
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
@@ -15,60 +16,93 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
interface AISettingsToggleProps {
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isInstanceAIConfigured: boolean;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
|
||||
export const AISettingsToggle = ({
|
||||
organization,
|
||||
membershipRole,
|
||||
isInstanceAIConfigured,
|
||||
}: Readonly<AISettingsToggleProps>) => {
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const canEdit = isOwner || isManager;
|
||||
const aiEnablementState = getOrganizationAIEnablementState({
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
||||
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
||||
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
||||
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAISmartToolsEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
||||
currentValue: organization.isAIDataAnalysisEnabled,
|
||||
isInstanceConfigured: isInstanceAIConfigured,
|
||||
});
|
||||
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
||||
toast.error(aiEnablementBlockedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const data =
|
||||
field === "isAISmartToolsEnabled"
|
||||
? { isAISmartToolsEnabled: checked }
|
||||
: { isAIDataAnalysisEnabled: checked };
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data: { [field]: checked },
|
||||
data,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
toast.error(getFormattedErrorMessage(response));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setLoadingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{showInstanceConfigWarning && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAISmartToolsEnabled}
|
||||
isChecked={displayedSmartToolsValue}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAIDataAnalysisEnabled}
|
||||
isChecked={displayedDataAnalysisValue}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
disabled={isToggleDisabled}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -64,7 +65,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.ai_enabled")}
|
||||
description={t("environments.settings.general.ai_enabled_description")}>
|
||||
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
|
||||
<AISettingsToggle
|
||||
organization={organization}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
|
||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationAISettingsInput,
|
||||
});
|
||||
+8
-1
@@ -29,6 +29,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -201,7 +202,13 @@ export const ResponseTable = ({
|
||||
};
|
||||
|
||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||
const result = await deleteResponseAction({
|
||||
responseId,
|
||||
decrementQuotas: params?.decrementQuotas ?? false,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
|
||||
+3
-1
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
||||
defaultValue: "Average time to complete the survey.",
|
||||
})}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ export const PersonalLinksTab = ({
|
||||
<UpgradePrompt
|
||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
feature="personal_links"
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
|
||||
+4
-4
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("calculates meta correctly", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
||||
expect(meta.displayCount).toBe(10);
|
||||
expect(meta.totalResponses).toBe(3);
|
||||
expect(meta.startsPercentage).toBe(30);
|
||||
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("handles zero display count", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
||||
expect(meta.startsPercentage).toBe(0);
|
||||
expect(meta.completedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("handles zero responses", () => {
|
||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
||||
expect(meta.totalResponses).toBe(0);
|
||||
expect(meta.completedResponses).toBe(0);
|
||||
expect(meta.dropOffCount).toBe(0);
|
||||
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
|
||||
});
|
||||
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
|
||||
+41
-8
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
|
||||
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
|
||||
block.elements.forEach((element) => {
|
||||
acc[element.id] = block.id;
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getBlockTimesForResponse = (
|
||||
response: TSurveySummaryResponse,
|
||||
survey: TSurvey
|
||||
): Record<string, number> => {
|
||||
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
|
||||
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
|
||||
const elementTtc = response.ttc?.[element.id] ?? 0;
|
||||
return Math.max(maxTtc, elementTtc);
|
||||
}, 0);
|
||||
|
||||
acc[block.id] = maxElementTtc;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getSurveySummaryMeta = (
|
||||
survey: TSurvey,
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number,
|
||||
quotas: TSurveySummary["quotas"]
|
||||
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
|
||||
|
||||
let ttcResponseCount = 0;
|
||||
const ttcSum = responses.reduce((acc, response) => {
|
||||
if (response.ttc?._total) {
|
||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
|
||||
|
||||
// Fallback to _total for malformed surveys with no block mappings.
|
||||
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
|
||||
|
||||
if (responseTtcTotal > 0) {
|
||||
ttcResponseCount++;
|
||||
return acc + response.ttc._total;
|
||||
return acc + responseTtcTotal;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
|
||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion per element
|
||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
const blockId = elementIdToBlockId[elementId];
|
||||
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
||||
if (blockTtc > 0) {
|
||||
totalTtc[elementId] += blockTtc;
|
||||
responseCounts[elementId]++;
|
||||
}
|
||||
});
|
||||
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||
const [meta, elementSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getElementSummary(survey, elements, responses, dropOff),
|
||||
]);
|
||||
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
||||
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
||||
|
||||
return {
|
||||
meta,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
@@ -23,9 +24,11 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -39,11 +42,20 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
return await getResponseDownloadFile(
|
||||
const result = await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataAction = z.object({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -45,6 +46,12 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
showReconnectButton?: boolean;
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
showReconnectButton = false,
|
||||
}: AirtableWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
airtableIntegration ? airtableIntegration.config?.key : false
|
||||
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
locale={locale}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||
/>
|
||||
) : (
|
||||
<ConnectIntegration
|
||||
|
||||
+38
-9
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
locale: TUserLocale;
|
||||
showReconnectButton: boolean;
|
||||
handleAirtableAuthorization: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
environmentId,
|
||||
setIsConnected,
|
||||
surveys,
|
||||
airtableArray,
|
||||
showReconnectButton,
|
||||
handleAirtableAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
: { isEditMode: false as const };
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
<div className="flex w-full justify-end gap-x-6">
|
||||
<div className="flex items-center">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
|
||||
<AlertButton onClick={handleAirtableAuthorization}>
|
||||
{t("environments.integrations.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="cursor-pointer text-slate-500">
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDefaultValues(null);
|
||||
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+9
-1
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
try {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||
isTokenValid = false;
|
||||
}
|
||||
}
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -13,7 +13,9 @@ import notionLogo from "@/images/notion.png";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
@@ -53,7 +55,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
integrations.some((integration) => integration.type === type);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
|
||||
interface SurveyResponsePostHogEventParams {
|
||||
organizationId: string;
|
||||
surveyId: string;
|
||||
surveyType: string;
|
||||
environmentId: string;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a PostHog event for survey responses at milestones:
|
||||
* 1st response, then every 100th (100, 200, 300, ...).
|
||||
*/
|
||||
export const captureSurveyResponsePostHogEvent = ({
|
||||
organizationId,
|
||||
surveyId,
|
||||
surveyType,
|
||||
environmentId,
|
||||
responseCount,
|
||||
}: SurveyResponsePostHogEventParams): void => {
|
||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
||||
|
||||
capturePostHogEvent(organizationId, "survey_response_received", {
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
});
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -24,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const requestHeaders = await headers();
|
||||
@@ -90,10 +91,15 @@ export const POST = async (request: Request) => {
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
// Fetch with timeout of 5 seconds to prevent hanging.
|
||||
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
|
||||
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
|
||||
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||
// pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
fetch(url, { ...options, redirect: redirectMode }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
@@ -299,6 +305,18 @@ export const POST = async (request: Request) => {
|
||||
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId: organization.id,
|
||||
surveyId,
|
||||
surveyType: survey.type,
|
||||
environmentId,
|
||||
responseCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Send telemetry events
|
||||
await sendTelemetryEvents();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
type WrappedAuthOptions = {
|
||||
callbacks: {
|
||||
signIn: (params: { user: unknown; account: unknown }) => Promise<boolean | string>;
|
||||
};
|
||||
events: {
|
||||
signIn: (params: { user: unknown; account: unknown; isNewUser: boolean }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const nextAuth = vi.fn(() => nextAuthHandler);
|
||||
const nextAuth = vi.fn((_authOptions: WrappedAuthOptions) => nextAuthHandler);
|
||||
|
||||
return {
|
||||
nextAuth,
|
||||
@@ -54,7 +63,7 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: mocks.queueAuditEventBackground,
|
||||
}));
|
||||
|
||||
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
|
||||
const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<WrappedAuthOptions> => {
|
||||
const request = new Request("http://localhost/api/auth/signin", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
@@ -63,7 +72,17 @@ const getWrappedAuthOptions = async (requestId: string = "req-123") => {
|
||||
|
||||
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
return mocks.nextAuth.mock.calls[0][0];
|
||||
const firstCall = mocks.nextAuth.mock.calls.at(0);
|
||||
if (!firstCall) {
|
||||
throw new Error("NextAuth was not called");
|
||||
}
|
||||
|
||||
const [authOptions] = firstCall;
|
||||
if (!authOptions) {
|
||||
throw new Error("NextAuth options were not provided");
|
||||
}
|
||||
|
||||
return authOptions;
|
||||
};
|
||||
|
||||
describe("auth route audit logging", () => {
|
||||
@@ -136,4 +155,34 @@ describe("auth route audit logging", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("logs blocked SSO account-linking attempts as SSO failures", async () => {
|
||||
const error = new Error("OAuthAccountNotLinked");
|
||||
mocks.baseSignIn.mockRejectedValueOnce(error);
|
||||
|
||||
const authOptions = await getWrappedAuthOptions("req-sso-failure");
|
||||
const user = { id: "user_3", email: "user3@example.com" };
|
||||
const account = { provider: "google" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: "user_3",
|
||||
targetId: "user_3",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
eventId: "req-sso-failure",
|
||||
newObject: expect.objectContaining({
|
||||
email: "user3@example.com",
|
||||
authMethod: "sso",
|
||||
provider: "google",
|
||||
errorMessage: "OAuthAccountNotLinked",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getResponseIdByDisplayId = async (
|
||||
environmentId: string,
|
||||
displayId: string
|
||||
): Promise<{ responseId: string | null }> => {
|
||||
validateInputs([environmentId, ZId], [displayId, ZId]);
|
||||
|
||||
try {
|
||||
const display = await prisma.display.findFirst({
|
||||
where: {
|
||||
id: displayId,
|
||||
survey: {
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
response: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!display) {
|
||||
throw new ResourceNotFoundError("Display", displayId);
|
||||
}
|
||||
|
||||
return {
|
||||
responseId: display.response?.id ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getResponseIdByDisplayId } from "./lib/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Display", params.displayId, true),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
|
||||
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/typ
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
@@ -36,6 +37,11 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
IS_PRODUCTION: true,
|
||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @formbricks/cache
|
||||
@@ -303,4 +309,38 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.actionClasses).toEqual([]);
|
||||
});
|
||||
|
||||
test("should capture app_connected PostHog event when app setup completes", async () => {
|
||||
const noCodeAction = {
|
||||
...mockActionClasses[0],
|
||||
id: "action-2",
|
||||
type: "noCode" as const,
|
||||
key: null,
|
||||
};
|
||||
const incompleteEnvironmentData = {
|
||||
...mockEnvironmentStateData,
|
||||
environment: {
|
||||
...mockEnvironmentStateData.environment,
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
actionClasses: [...mockActionClasses, noCodeAction],
|
||||
};
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
||||
|
||||
await getEnvironmentState(environmentId);
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not capture app_connected event when app setup already completed", async () => {
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
||||
|
||||
await getEnvironmentState(environmentId);
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,14 @@ export const getEnvironmentState = async (
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
capturePostHogEvent(environmentId, "app_connected", {
|
||||
num_surveys: surveys.length,
|
||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build the response data
|
||||
|
||||
@@ -5,7 +5,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -76,16 +78,31 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||
const existingData = existingIntegration?.config?.data ?? [];
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
data: existingData,
|
||||
email,
|
||||
},
|
||||
};
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { getAirtableToken, getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
const integration = await getIntegrationByType(environmentId, "airtable");
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
// Use getAirtableToken to ensure the access token is refreshed if expired
|
||||
const freshAccessToken = await getAirtableToken(environmentId);
|
||||
const tables = await getTables(
|
||||
{ ...integration.config.key, access_token: freshAccessToken },
|
||||
baseId.data
|
||||
);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
TIntegrationSlackConfig,
|
||||
TIntegrationSlackConfigData,
|
||||
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
headers: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
parseApiKeyV2: vi.fn(),
|
||||
hashSha256: vi.fn(),
|
||||
verifySecret: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
notAuthenticatedResponse: vi.fn(
|
||||
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
|
||||
),
|
||||
tooManyRequestsResponse: vi.fn(
|
||||
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
|
||||
),
|
||||
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: mocks.headers,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
|
||||
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
|
||||
badRequestResponse: mocks.badRequestResponse,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
hashSha256: mocks.hashSha256,
|
||||
parseApiKeyV2: mocks.parseApiKeyV2,
|
||||
verifySecret: mocks.verifySecret,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: mocks.applyRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v1: { windowMs: 60_000, max: 1000 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const getMockHeaders = (apiKey: string | null) => ({
|
||||
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
|
||||
});
|
||||
|
||||
describe("v1 management me route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.headers.mockResolvedValue(getMockHeaders(null));
|
||||
mocks.getSessionUser.mockResolvedValue(undefined);
|
||||
mocks.parseApiKeyV2.mockReturnValue(null);
|
||||
mocks.hashSha256.mockReturnValue("hashed-api-key");
|
||||
mocks.verifySecret.mockResolvedValue(false);
|
||||
mocks.applyRateLimit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns a sanitized authenticated user for session-based requests", async () => {
|
||||
const publicUser = {
|
||||
id: "user_123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
|
||||
createdAt: new Date("2025-04-17T20:09:14.021Z"),
|
||||
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email" as const,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US" as const,
|
||||
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
|
||||
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
|
||||
expect(responseBody).not.toHaveProperty("password");
|
||||
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: publicUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
|
||||
});
|
||||
|
||||
test("returns the existing unauthenticated response when no session is present", async () => {
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(responseBody).toEqual({ message: "Not authenticated" });
|
||||
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("preserves the API key response path", async () => {
|
||||
const apiKeyData = {
|
||||
id: "api_key_123",
|
||||
organizationId: "org_123",
|
||||
hashedKey: "stored-hash",
|
||||
lastUsedAt: new Date(),
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
permission: "manage",
|
||||
environment: {
|
||||
id: "env_123",
|
||||
type: "development",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
projectId: "project_123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project_123",
|
||||
name: "My Project",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
|
||||
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(responseBody).toStrictEqual({
|
||||
id: "env_123",
|
||||
type: "development",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project_123",
|
||||
name: "My Project",
|
||||
},
|
||||
});
|
||||
expect(mocks.getSessionUser).not.toHaveBeenCalled();
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
@@ -49,6 +50,11 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "organization_created", {
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
})
|
||||
);
|
||||
|
||||
+25
-3
@@ -51,6 +51,8 @@ checksums:
|
||||
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
|
||||
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
|
||||
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
|
||||
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
|
||||
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
|
||||
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
|
||||
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
|
||||
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
|
||||
@@ -121,6 +123,8 @@ checksums:
|
||||
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
|
||||
common/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
|
||||
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
@@ -290,6 +294,9 @@ checksums:
|
||||
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
|
||||
common/number: 2789f8391f63e7200a5521078aab017d
|
||||
common/off: 7e33a70258401abe652c9f1b595fabda
|
||||
common/offline_all_responses_synced: 9760250890a3a1901163c3f9e91602cc
|
||||
common/offline_syncing_responses: 6a518ff0248a29216b60a206e6966d4d
|
||||
common/offline_you_are_offline: c8fa81ac3e9cad1c64b963ec3f372085
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
@@ -406,6 +413,7 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -476,7 +484,7 @@ checksums:
|
||||
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
|
||||
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
|
||||
emails/forgot_password_email_link_valid_for_24_hours: 962358a7f9674f13f49278afa15d14d3
|
||||
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
|
||||
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
|
||||
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
@@ -776,6 +784,9 @@ checksums:
|
||||
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
|
||||
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
|
||||
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
|
||||
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
|
||||
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
|
||||
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
|
||||
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
|
||||
@@ -1068,11 +1079,15 @@ checksums:
|
||||
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
|
||||
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
|
||||
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
|
||||
environments/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
|
||||
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
|
||||
environments/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
|
||||
environments/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
|
||||
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
|
||||
environments/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
|
||||
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
|
||||
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
|
||||
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
|
||||
@@ -1113,6 +1128,7 @@ checksums:
|
||||
environments/settings/general/only_org_owner_can_perform_action: 0244ff3c6de787935e592eac4c5e4f0b
|
||||
environments/settings/general/organization_created_successfully: 1ce874980bdd7d5de8402c276fb97a57
|
||||
environments/settings/general/organization_deleted_successfully: e51fd7ee9efda04a373450ea21b242db
|
||||
environments/settings/general/organization_deletion_disabled: 6fb0e71c218ed4ab3be175f1094feada
|
||||
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
|
||||
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
|
||||
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
|
||||
@@ -1696,6 +1712,11 @@ checksums:
|
||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
|
||||
environments/surveys/edit/validate_id_duplicate: f88ec35a9bd4921fb096817b9263b59a
|
||||
environments/surveys/edit/validate_id_empty: 3ee25d429ed5ca9e047f9aee95496323
|
||||
environments/surveys/edit/validate_id_invalid_chars: 50239938a408c04b02d77b8cd096d767
|
||||
environments/surveys/edit/validate_id_no_spaces: 11c4408574c11c51f30fe98be18baadb
|
||||
environments/surveys/edit/validate_id_reserved: 2696af4715ee91539e247b1b9a931721
|
||||
environments/surveys/edit/validation/add_validation_rule: e0c3208977140e5475df3e9b08927dbf
|
||||
environments/surveys/edit/validation/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0
|
||||
environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259
|
||||
@@ -2006,6 +2027,7 @@ checksums:
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
|
||||
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
@@ -2464,8 +2486,8 @@ checksums:
|
||||
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
|
||||
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
|
||||
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
|
||||
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae
|
||||
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
|
||||
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
|
||||
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
|
||||
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
|
||||
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
|
||||
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
type TAccountDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TAccountDbClient => tx ?? prisma;
|
||||
|
||||
export const createAccount = async (accountData: TAccountInput): Promise<TAccount> => {
|
||||
validateInputs([accountData, ZAccountInput]);
|
||||
|
||||
@@ -21,7 +25,10 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
|
||||
export const upsertAccount = async (
|
||||
accountData: TAccountInput,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TAccount> => {
|
||||
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
|
||||
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
|
||||
access_token: validatedAccountData.access_token,
|
||||
@@ -33,7 +40,7 @@ export const upsertAccount = async (accountData: TAccountInput): Promise<TAccoun
|
||||
};
|
||||
|
||||
try {
|
||||
const account = await prisma.account.upsert({
|
||||
const account = await getDbClient(tx).account.upsert({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: validatedAccountData.provider,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
assertOrganizationAIConfigured,
|
||||
generateOrganizationAIText,
|
||||
getOrganizationAIConfig,
|
||||
isInstanceAIConfigured,
|
||||
} from "./service";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
generateText: vi.fn(),
|
||||
isAiConfigured: vi.fn(),
|
||||
getOrganization: vi.fn(),
|
||||
getIsAIDataAnalysisEnabled: vi.fn(),
|
||||
getIsAISmartToolsEnabled: vi.fn(),
|
||||
getTranslate: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/ai", () => ({
|
||||
AIConfigurationError: class AIConfigurationError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
},
|
||||
generateText: mocks.generateText,
|
||||
isAiConfigured: mocks.isAiConfigured,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: mocks.loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
AI_PROVIDER: "gcp",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GCP_PROJECT: "vertex-project",
|
||||
AI_GCP_LOCATION: "us-central1",
|
||||
AI_GCP_CREDENTIALS_JSON: undefined,
|
||||
AI_GCP_APPLICATION_CREDENTIALS: "/tmp/vertex.json",
|
||||
AI_AWS_REGION: "us-east-1",
|
||||
AI_AWS_ACCESS_KEY_ID: "aws-access-key-id",
|
||||
AI_AWS_SECRET_ACCESS_KEY: "aws-secret-access-key",
|
||||
AI_AWS_SESSION_TOKEN: undefined,
|
||||
AI_AZURE_BASE_URL: "https://example-resource.openai.azure.com/openai",
|
||||
AI_AZURE_RESOURCE_NAME: undefined,
|
||||
AI_AZURE_API_KEY: "azure-api-key",
|
||||
AI_AZURE_API_VERSION: "v1",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: mocks.getOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsAIDataAnalysisEnabled: mocks.getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled: mocks.getIsAISmartToolsEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
getTranslate: mocks.getTranslate,
|
||||
}));
|
||||
|
||||
describe("AI organization service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.isAiConfigured.mockReturnValue(true);
|
||||
mocks.getOrganization.mockResolvedValue({
|
||||
id: "org_1",
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
});
|
||||
mocks.getIsAISmartToolsEnabled.mockResolvedValue(true);
|
||||
mocks.getIsAIDataAnalysisEnabled.mockResolvedValue(true);
|
||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||
values ? `${key}:${JSON.stringify(values)}` : key
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the instance AI status and organization settings", async () => {
|
||||
const configured = isInstanceAIConfigured();
|
||||
const result = await getOrganizationAIConfig("org_1");
|
||||
|
||||
expect(configured).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
organizationId: "org_1",
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
isAISmartToolsEntitled: true,
|
||||
isAIDataAnalysisEntitled: true,
|
||||
isInstanceConfigured: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when the organization cannot be found", async () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrganizationAIConfig("org_missing")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("fails closed when the organization is not entitled to AI", async () => {
|
||||
mocks.getIsAISmartToolsEnabled.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed when the requested AI capability is disabled", async () => {
|
||||
mocks.getOrganization.mockResolvedValueOnce({
|
||||
id: "org_1",
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
});
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed when the instance AI configuration is incomplete", async () => {
|
||||
mocks.isAiConfigured.mockReturnValueOnce(false);
|
||||
|
||||
await expect(assertOrganizationAIConfigured("org_1", "smartTools")).rejects.toThrow(
|
||||
OperationNotAllowedError
|
||||
);
|
||||
});
|
||||
|
||||
test("generates organization AI text with the configured package abstraction", async () => {
|
||||
const generatedText = { text: "Translated text" };
|
||||
mocks.generateText.mockResolvedValueOnce(generatedText);
|
||||
|
||||
const result = await generateOrganizationAIText({
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
prompt: "Translate this survey",
|
||||
});
|
||||
|
||||
expect(result).toBe(generatedText);
|
||||
expect(mocks.generateText).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt: "Translate this survey",
|
||||
},
|
||||
expect.objectContaining({
|
||||
AI_PROVIDER: "gcp",
|
||||
AI_MODEL: "gemini-2.5-flash",
|
||||
AI_GCP_PROJECT: "vertex-project",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and rethrows generation errors", async () => {
|
||||
const modelError = new Error("provider boom");
|
||||
mocks.generateText.mockRejectedValueOnce(modelError);
|
||||
|
||||
await expect(
|
||||
generateOrganizationAIText({
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
prompt: "Translate this survey",
|
||||
})
|
||||
).rejects.toThrow(modelError);
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
organizationId: "org_1",
|
||||
capability: "smartTools",
|
||||
isInstanceConfigured: true,
|
||||
errorCode: undefined,
|
||||
err: modelError,
|
||||
},
|
||||
"Failed to generate organization AI text"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import "server-only";
|
||||
import { AIConfigurationError, generateText, isAiConfigured } from "@formbricks/ai";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { env } from "@/lib/env";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsAIDataAnalysisEnabled, getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
export interface TOrganizationAIConfig {
|
||||
organizationId: string;
|
||||
isAISmartToolsEnabled: boolean;
|
||||
isAIDataAnalysisEnabled: boolean;
|
||||
isAISmartToolsEntitled: boolean;
|
||||
isAIDataAnalysisEntitled: boolean;
|
||||
isInstanceConfigured: boolean;
|
||||
}
|
||||
|
||||
export const isInstanceAIConfigured = (): boolean => isAiConfigured(env);
|
||||
|
||||
export const getOrganizationAIConfig = async (organizationId: string): Promise<TOrganizationAIConfig> => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const [isAISmartToolsEntitled, isAIDataAnalysisEntitled] = await Promise.all([
|
||||
getIsAISmartToolsEnabled(organizationId),
|
||||
getIsAIDataAnalysisEnabled(organizationId),
|
||||
]);
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
isAISmartToolsEntitled,
|
||||
isAIDataAnalysisEntitled,
|
||||
isInstanceConfigured: isInstanceAIConfigured(),
|
||||
};
|
||||
};
|
||||
|
||||
export const assertOrganizationAIConfigured = async (
|
||||
organizationId: string,
|
||||
capability: "smartTools" | "dataAnalysis"
|
||||
): Promise<TOrganizationAIConfig> => {
|
||||
const t = await getTranslate();
|
||||
const aiConfig = await getOrganizationAIConfig(organizationId);
|
||||
const isCapabilityEntitled =
|
||||
capability === "smartTools" ? aiConfig.isAISmartToolsEntitled : aiConfig.isAIDataAnalysisEntitled;
|
||||
|
||||
if (!isCapabilityEntitled) {
|
||||
throw new OperationNotAllowedError(
|
||||
t("environments.settings.general.ai_features_not_enabled_for_organization")
|
||||
);
|
||||
}
|
||||
|
||||
if (capability === "smartTools" && !aiConfig.isAISmartToolsEnabled) {
|
||||
throw new OperationNotAllowedError(
|
||||
t("environments.settings.general.ai_smart_tools_disabled_for_organization")
|
||||
);
|
||||
}
|
||||
|
||||
if (capability === "dataAnalysis" && !aiConfig.isAIDataAnalysisEnabled) {
|
||||
throw new OperationNotAllowedError(
|
||||
t("environments.settings.general.ai_data_analysis_disabled_for_organization")
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiConfig.isInstanceConfigured) {
|
||||
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
||||
}
|
||||
|
||||
return aiConfig;
|
||||
};
|
||||
|
||||
type TGenerateOrganizationAITextInput = {
|
||||
organizationId: string;
|
||||
capability: "smartTools" | "dataAnalysis";
|
||||
} & Parameters<typeof generateText>[0];
|
||||
|
||||
export const generateOrganizationAIText = async ({
|
||||
organizationId,
|
||||
capability,
|
||||
...options
|
||||
}: TGenerateOrganizationAITextInput): Promise<Awaited<ReturnType<typeof generateText>>> => {
|
||||
const aiConfig = await assertOrganizationAIConfigured(organizationId, capability);
|
||||
|
||||
try {
|
||||
return await generateText(options, env);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
organizationId,
|
||||
capability,
|
||||
isInstanceConfigured: aiConfig.isInstanceConfigured,
|
||||
errorCode: error instanceof AIConfigurationError ? error.code : undefined,
|
||||
err: error,
|
||||
},
|
||||
"Failed to generate organization AI text"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "./utils";
|
||||
|
||||
describe("getOrganizationAIEnablementState", () => {
|
||||
test("blocks enabling when instance AI is not configured", () => {
|
||||
expect(
|
||||
getOrganizationAIEnablementState({
|
||||
isInstanceConfigured: false,
|
||||
})
|
||||
).toMatchObject({
|
||||
canEnableFeatures: false,
|
||||
blockReason: "instanceNotConfigured",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows enabling when instance AI is configured", () => {
|
||||
expect(
|
||||
getOrganizationAIEnablementState({
|
||||
isInstanceConfigured: true,
|
||||
})
|
||||
).toMatchObject({
|
||||
canEnableFeatures: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplayedOrganizationAISettingValue", () => {
|
||||
test("renders enabled settings as off when instance AI is not configured", () => {
|
||||
expect(
|
||||
getDisplayedOrganizationAISettingValue({
|
||||
currentValue: true,
|
||||
isInstanceConfigured: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("renders the stored setting value when instance AI is configured", () => {
|
||||
expect(
|
||||
getDisplayedOrganizationAISettingValue({
|
||||
currentValue: true,
|
||||
isInstanceConfigured: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("renders false when the stored setting is false and instance AI is configured", () => {
|
||||
expect(
|
||||
getDisplayedOrganizationAISettingValue({
|
||||
currentValue: false,
|
||||
isInstanceConfigured: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export type TAIEnablementBlockReason = "instanceNotConfigured";
|
||||
|
||||
interface TOrganizationAIEnablementState {
|
||||
canEnableFeatures: boolean;
|
||||
blockReason?: TAIEnablementBlockReason;
|
||||
}
|
||||
|
||||
export const getDisplayedOrganizationAISettingValue = ({
|
||||
currentValue,
|
||||
isInstanceConfigured,
|
||||
}: {
|
||||
currentValue: boolean;
|
||||
isInstanceConfigured: boolean;
|
||||
}): boolean => isInstanceConfigured && currentValue;
|
||||
|
||||
export const getOrganizationAIEnablementState = ({
|
||||
isInstanceConfigured,
|
||||
}: {
|
||||
isInstanceConfigured: boolean;
|
||||
}): TOrganizationAIEnablementState => {
|
||||
if (!isInstanceConfigured) {
|
||||
return {
|
||||
canEnableFeatures: false,
|
||||
blockReason: "instanceNotConfigured",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canEnableFeatures: true,
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
ZIntegrationAirtableBases,
|
||||
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!req.ok) {
|
||||
const body = await req.text().catch(() => "");
|
||||
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
|
||||
}
|
||||
|
||||
const res = await req.json();
|
||||
return ZIntegrationAirtableBases.parse(res);
|
||||
};
|
||||
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
|
||||
},
|
||||
});
|
||||
|
||||
if (!req.ok) {
|
||||
const body = await req.text().catch(() => "");
|
||||
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
|
||||
}
|
||||
|
||||
const res = await req.json();
|
||||
|
||||
return res;
|
||||
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
|
||||
|
||||
export const getAirtableToken = async (environmentId: string) => {
|
||||
try {
|
||||
const airtableIntegration = (await getIntegrationByType(
|
||||
environmentId,
|
||||
"airtable"
|
||||
)) as TIntegrationAirtable;
|
||||
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||
|
||||
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
||||
airtableIntegration?.config.key
|
||||
|
||||
+140
-2
@@ -1,12 +1,120 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
import { AI_PROVIDERS } from "@formbricks/types/ai";
|
||||
|
||||
export const env = createEnv({
|
||||
const ZActiveAIProvider = z.enum(AI_PROVIDERS);
|
||||
const ZAIConfigurationEnv = z.object({
|
||||
AI_PROVIDER: ZActiveAIProvider.optional(),
|
||||
AI_MODEL: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
AI_GCP_LOCATION: z.string().optional(),
|
||||
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
AI_AWS_REGION: z.string().optional(),
|
||||
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
AI_AZURE_API_KEY: z.string().optional(),
|
||||
AI_AZURE_BASE_URL: z.url().optional(),
|
||||
AI_AZURE_RESOURCE_NAME: z.string().optional(),
|
||||
});
|
||||
|
||||
type TAIConfigurationEnv = z.infer<typeof ZAIConfigurationEnv>;
|
||||
|
||||
const addEnvIssue = (ctx: z.RefinementCtx, path: keyof TAIConfigurationEnv, message: string): void => {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [path],
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
const validateActiveAIModel = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (values.AI_PROVIDER && !values.AI_MODEL) {
|
||||
addEnvIssue(ctx, "AI_MODEL", "AI_MODEL is required when AI_PROVIDER is set");
|
||||
}
|
||||
};
|
||||
|
||||
const validateAwsAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (!values.AI_AWS_REGION) {
|
||||
addEnvIssue(ctx, "AI_AWS_REGION", "AI_AWS_REGION is required when AI_PROVIDER=aws");
|
||||
}
|
||||
|
||||
if (!values.AI_AWS_ACCESS_KEY_ID) {
|
||||
addEnvIssue(ctx, "AI_AWS_ACCESS_KEY_ID", "AI_AWS_ACCESS_KEY_ID is required when AI_PROVIDER=aws");
|
||||
}
|
||||
|
||||
if (!values.AI_AWS_SECRET_ACCESS_KEY) {
|
||||
addEnvIssue(ctx, "AI_AWS_SECRET_ACCESS_KEY", "AI_AWS_SECRET_ACCESS_KEY is required when AI_PROVIDER=aws");
|
||||
}
|
||||
};
|
||||
|
||||
const validateGcpAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (!values.AI_GCP_PROJECT) {
|
||||
addEnvIssue(ctx, "AI_GCP_PROJECT", "AI_GCP_PROJECT is required when AI_PROVIDER=gcp");
|
||||
}
|
||||
|
||||
if (!values.AI_GCP_LOCATION) {
|
||||
addEnvIssue(ctx, "AI_GCP_LOCATION", "AI_GCP_LOCATION is required when AI_PROVIDER=gcp");
|
||||
}
|
||||
|
||||
if (!values.AI_GCP_CREDENTIALS_JSON && !values.AI_GCP_APPLICATION_CREDENTIALS) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_GCP_CREDENTIALS_JSON",
|
||||
"AI_GCP_CREDENTIALS_JSON or AI_GCP_APPLICATION_CREDENTIALS is required when AI_PROVIDER=gcp"
|
||||
);
|
||||
}
|
||||
|
||||
if (values.AI_GCP_CREDENTIALS_JSON) {
|
||||
try {
|
||||
JSON.parse(values.AI_GCP_CREDENTIALS_JSON);
|
||||
} catch {
|
||||
addEnvIssue(ctx, "AI_GCP_CREDENTIALS_JSON", "AI_GCP_CREDENTIALS_JSON must be valid JSON");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateAzureAIConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
if (!values.AI_AZURE_API_KEY) {
|
||||
addEnvIssue(ctx, "AI_AZURE_API_KEY", "AI_AZURE_API_KEY is required when AI_PROVIDER=azure");
|
||||
}
|
||||
|
||||
if (!values.AI_AZURE_BASE_URL && !values.AI_AZURE_RESOURCE_NAME) {
|
||||
addEnvIssue(
|
||||
ctx,
|
||||
"AI_AZURE_BASE_URL",
|
||||
"AI_AZURE_BASE_URL or AI_AZURE_RESOURCE_NAME is required when AI_PROVIDER=azure"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateActiveAIProviderConfiguration = (values: TAIConfigurationEnv, ctx: z.RefinementCtx): void => {
|
||||
validateActiveAIModel(values, ctx);
|
||||
|
||||
if (!values.AI_PROVIDER) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providerValidators: Record<
|
||||
z.infer<typeof ZActiveAIProvider>,
|
||||
(values: TAIConfigurationEnv, ctx: z.RefinementCtx) => void
|
||||
> = {
|
||||
aws: validateAwsAIConfiguration,
|
||||
gcp: validateGcpAIConfiguration,
|
||||
azure: validateAzureAIConfiguration,
|
||||
};
|
||||
|
||||
providerValidators[values.AI_PROVIDER](values, ctx);
|
||||
};
|
||||
|
||||
const parsedEnv = createEnv({
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
AI_PROVIDER: ZActiveAIProvider.optional(),
|
||||
AI_MODEL: z.string().optional(),
|
||||
AIRTABLE_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_ID: z.string().optional(),
|
||||
AZUREAD_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -30,9 +138,21 @@ export const env = createEnv({
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
AI_GCP_LOCATION: z.string().optional(),
|
||||
AI_GCP_CREDENTIALS_JSON: z.string().optional(),
|
||||
AI_GCP_APPLICATION_CREDENTIALS: z.string().optional(),
|
||||
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
AI_AWS_REGION: z.string().optional(),
|
||||
AI_AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AI_AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
AI_AWS_SESSION_TOKEN: z.string().optional(),
|
||||
AI_AZURE_BASE_URL: z.url().optional(),
|
||||
AI_AZURE_API_KEY: z.string().optional(),
|
||||
AI_AZURE_API_VERSION: z.string().optional(),
|
||||
AI_AZURE_RESOURCE_NAME: z.string().optional(),
|
||||
HTTP_PROXY: z.url().optional(),
|
||||
HTTPS_PROXY: z.url().optional(),
|
||||
IMPRINT_URL: z
|
||||
@@ -125,7 +245,7 @@ export const env = createEnv({
|
||||
AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(),
|
||||
SESSION_MAX_AGE: z
|
||||
.string()
|
||||
.transform((val) => parseInt(val))
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
},
|
||||
@@ -137,6 +257,8 @@ export const env = createEnv({
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
AI_PROVIDER: process.env.AI_PROVIDER,
|
||||
AI_MODEL: process.env.AI_MODEL,
|
||||
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
|
||||
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
|
||||
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
|
||||
@@ -160,9 +282,21 @@ export const env = createEnv({
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
|
||||
AI_GCP_LOCATION: process.env.AI_GCP_LOCATION,
|
||||
AI_GCP_CREDENTIALS_JSON: process.env.AI_GCP_CREDENTIALS_JSON,
|
||||
AI_GCP_APPLICATION_CREDENTIALS: process.env.AI_GCP_APPLICATION_CREDENTIALS,
|
||||
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
AI_AWS_REGION: process.env.AI_AWS_REGION,
|
||||
AI_AWS_ACCESS_KEY_ID: process.env.AI_AWS_ACCESS_KEY_ID,
|
||||
AI_AWS_SECRET_ACCESS_KEY: process.env.AI_AWS_SECRET_ACCESS_KEY,
|
||||
AI_AWS_SESSION_TOKEN: process.env.AI_AWS_SESSION_TOKEN,
|
||||
AI_AZURE_BASE_URL: process.env.AI_AZURE_BASE_URL,
|
||||
AI_AZURE_API_KEY: process.env.AI_AZURE_API_KEY,
|
||||
AI_AZURE_API_VERSION: process.env.AI_AZURE_API_VERSION,
|
||||
AI_AZURE_RESOURCE_NAME: process.env.AI_AZURE_RESOURCE_NAME,
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
@@ -229,3 +363,7 @@ export const env = createEnv({
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
export const env = ZAIConfigurationEnv.superRefine(validateActiveAIProviderConfiguration)
|
||||
.transform(() => parsedEnv)
|
||||
.parse(parsedEnv);
|
||||
|
||||
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegration,
|
||||
TIntegrationByType,
|
||||
TIntegrationInput,
|
||||
ZIntegrationType,
|
||||
} from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
|
||||
});
|
||||
|
||||
export const getIntegrationByType = reactCache(
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
async <T extends TIntegrationInput["type"]>(
|
||||
environmentId: string,
|
||||
type: T
|
||||
): Promise<TIntegrationByType<T> | null> => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
|
||||
try {
|
||||
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
|
||||
},
|
||||
},
|
||||
});
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getBillingFallbackPath } from "./navigation";
|
||||
|
||||
describe("getBillingFallbackPath", () => {
|
||||
test("returns billing settings path for cloud", () => {
|
||||
const path = getBillingFallbackPath("env_123", true);
|
||||
expect(path).toBe("/environments/env_123/settings/billing");
|
||||
});
|
||||
|
||||
test("returns enterprise settings path for self-hosted", () => {
|
||||
const path = getBillingFallbackPath("env_123", false);
|
||||
expect(path).toBe("/environments/env_123/settings/enterprise");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const getBillingFallbackPath = (environmentId: string, isFormbricksCloud: boolean): string => {
|
||||
const settingsPath = isFormbricksCloud ? "billing" : "enterprise";
|
||||
return `/environments/${environmentId}/settings/${settingsPath}`;
|
||||
};
|
||||
@@ -68,6 +68,33 @@ describe("Membership Service", () => {
|
||||
|
||||
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
|
||||
});
|
||||
|
||||
test("uses the transaction client directly when provided", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
const tx = {
|
||||
membership: {
|
||||
findUnique: vi.fn().mockResolvedValue(mockMembership),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId, tx);
|
||||
|
||||
expect(result).toEqual(mockMembership);
|
||||
expect(tx.membership.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prisma.membership.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMembership", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -8,43 +8,67 @@ import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const getMembershipByUserIdOrganizationId = reactCache(
|
||||
async (userId: string, organizationId: string): Promise<TMembership | null> => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
type TMembershipDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
try {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TMembershipDbClient => tx ?? prisma;
|
||||
|
||||
const getMembershipByUserIdOrganizationIdUncached = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TMembership | null> => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
|
||||
try {
|
||||
const membership = await getDbClient(tx).membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) return null;
|
||||
if (!membership) return null;
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw new UnknownError("Error while fetching membership");
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw new UnknownError("Error while fetching membership");
|
||||
}
|
||||
};
|
||||
|
||||
const getMembershipByUserIdOrganizationIdCached = reactCache(async (userId: string, organizationId: string) =>
|
||||
getMembershipByUserIdOrganizationIdUncached(userId, organizationId)
|
||||
);
|
||||
|
||||
export const getMembershipByUserIdOrganizationId = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TMembership | null> => {
|
||||
if (tx) {
|
||||
return getMembershipByUserIdOrganizationIdUncached(userId, organizationId, tx);
|
||||
}
|
||||
|
||||
return getMembershipByUserIdOrganizationIdCached(userId, organizationId);
|
||||
};
|
||||
|
||||
export const createMembership = async (
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
data: Partial<TMembership>
|
||||
data: Partial<TMembership>,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TMembership> => {
|
||||
validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]);
|
||||
|
||||
try {
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
const prismaClient = getDbClient(tx);
|
||||
const existingMembership = await prismaClient.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
@@ -59,7 +83,7 @@ export const createMembership = async (
|
||||
|
||||
let membership: TMembership;
|
||||
if (!existingMembership) {
|
||||
membership = await prisma.membership.create({
|
||||
membership = await prismaClient.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationId,
|
||||
@@ -68,7 +92,7 @@ export const createMembership = async (
|
||||
},
|
||||
});
|
||||
} else {
|
||||
membership = await prisma.membership.update({
|
||||
membership = await prismaClient.membership.update({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIntegrationByType } from "../integration/service";
|
||||
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
|
||||
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
||||
let results: TIntegrationNotionDatabase[] = [];
|
||||
try {
|
||||
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
|
||||
const notionIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
||||
results = await fetchPages(notionIntegration.config);
|
||||
}
|
||||
|
||||
@@ -367,35 +367,34 @@ export const subscribeOrganizationMembersToSurveyResponses = async (
|
||||
createdBy: string,
|
||||
organizationId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const surveyCreator = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: createdBy,
|
||||
},
|
||||
});
|
||||
const surveyCreator = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: createdBy,
|
||||
},
|
||||
});
|
||||
|
||||
if (!surveyCreator) {
|
||||
throw new ResourceNotFoundError("User", createdBy);
|
||||
}
|
||||
|
||||
if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSettings = { alert: {} };
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...defaultSettings,
|
||||
...surveyCreator.notificationSettings,
|
||||
};
|
||||
|
||||
updatedNotificationSettings.alert[surveyId] = true;
|
||||
|
||||
await updateUser(surveyCreator.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
if (!surveyCreator) {
|
||||
throw new ResourceNotFoundError("User", createdBy);
|
||||
}
|
||||
|
||||
if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSettings = { alert: {} as NonNullable<TUserNotificationSettings["alert"]> };
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...defaultSettings,
|
||||
...surveyCreator.notificationSettings,
|
||||
alert: surveyCreator.notificationSettings?.alert
|
||||
? { ...surveyCreator.notificationSettings.alert }
|
||||
: defaultSettings.alert,
|
||||
};
|
||||
|
||||
updatedNotificationSettings.alert[surveyId] = true;
|
||||
|
||||
await updateUser(surveyCreator.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrganizationsWhereUserIsSingleOwner = reactCache(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { capturePostHogEvent } from "./capture";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
|
||||
vi.mock("./server", () => ({
|
||||
posthogServerClient: { capture: mocks.capture },
|
||||
}));
|
||||
|
||||
describe("capturePostHogEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("calls posthog capture with correct params", () => {
|
||||
capturePostHogEvent("user123", "test_event", { key: "value" });
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
key: "value",
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("adds default properties when no properties provided", () => {
|
||||
capturePostHogEvent("user123", "test_event");
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("does not throw when capture throws", () => {
|
||||
mocks.capture.mockImplementation(() => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
|
||||
expect(() => capturePostHogEvent("user123", "test_event")).not.toThrow();
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), eventName: "test_event" },
|
||||
"Failed to capture PostHog event"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capturePostHogEvent with null client", () => {
|
||||
test("no-ops when posthogServerClient is null", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
|
||||
|
||||
captureWithNullClient("user123", "test_event", { key: "value" });
|
||||
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { posthogServerClient } from "./server";
|
||||
|
||||
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export function capturePostHogEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: PostHogEventProperties
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
try {
|
||||
posthogServerClient.capture({
|
||||
distinctId,
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, eventName }, "Failed to capture PostHog event");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("server - posthogServerClient", () => {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
const setupMocks = (opts: {
|
||||
posthogKey?: string;
|
||||
shutdown?: ReturnType<typeof vi.fn>;
|
||||
loggerError?: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
const shutdown = opts.shutdown ?? vi.fn().mockResolvedValue(undefined);
|
||||
const loggerError = opts.loggerError ?? vi.fn();
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({ logger: { error: loggerError } }));
|
||||
vi.doMock("posthog-node", () => ({
|
||||
PostHog: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
||||
this.capture = vi.fn();
|
||||
this.shutdown = shutdown;
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: opts.posthogKey }));
|
||||
|
||||
return { shutdown, loggerError };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete g.posthogServerClient;
|
||||
delete g.posthogHandlersRegistered;
|
||||
});
|
||||
|
||||
test("returns null when POSTHOG_KEY is not set", async () => {
|
||||
setupMocks({ posthogKey: undefined });
|
||||
|
||||
const { posthogServerClient } = await import("./server");
|
||||
expect(posthogServerClient).toBeNull();
|
||||
});
|
||||
|
||||
test("creates PostHog client when POSTHOG_KEY is set", async () => {
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
|
||||
const { posthogServerClient } = await import("./server");
|
||||
expect(posthogServerClient).not.toBeNull();
|
||||
|
||||
const { PostHog } = await import("posthog-node");
|
||||
expect(PostHog).toHaveBeenCalledWith("phc_test_key", {
|
||||
host: "https://eu.i.posthog.com",
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("reuses client from globalThis in development", async () => {
|
||||
const fakeClient = { capture: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined) };
|
||||
g.posthogServerClient = fakeClient;
|
||||
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
|
||||
const { posthogServerClient } = await import("./server");
|
||||
expect(posthogServerClient).toBe(fakeClient);
|
||||
|
||||
const { PostHog } = await import("posthog-node");
|
||||
expect(PostHog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("caches client on globalThis in non-production", async () => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
|
||||
const { posthogServerClient } = await import("./server");
|
||||
expect(g.posthogServerClient).toBe(posthogServerClient);
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("registers signal handlers once when NEXT_RUNTIME is nodejs", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
const processOnSpy = vi.spyOn(process, "on");
|
||||
|
||||
await import("./server");
|
||||
|
||||
expect(processOnSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
|
||||
expect(processOnSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
|
||||
expect(g.posthogHandlersRegistered).toBe(true);
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("does not register signal handlers when already registered", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
g.posthogHandlersRegistered = true;
|
||||
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
const processOnSpy = vi.spyOn(process, "on");
|
||||
|
||||
await import("./server");
|
||||
|
||||
const sigCalls = processOnSpy.mock.calls.filter(([event]) => event === "SIGTERM" || event === "SIGINT");
|
||||
expect(sigCalls).toHaveLength(0);
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("does not register signal handlers when NEXT_RUNTIME is not nodejs", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "");
|
||||
|
||||
setupMocks({ posthogKey: "phc_test_key" });
|
||||
const processOnSpy = vi.spyOn(process, "on");
|
||||
|
||||
await import("./server");
|
||||
|
||||
const sigCalls = processOnSpy.mock.calls.filter(([event]) => event === "SIGTERM" || event === "SIGINT");
|
||||
expect(sigCalls).toHaveLength(0);
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("shutdown handler calls shutdown()", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
|
||||
const { shutdown } = setupMocks({ posthogKey: "phc_test_key" });
|
||||
|
||||
let sigTermHandler: (() => void) | undefined;
|
||||
const processOnSpy = vi.spyOn(process, "on").mockImplementation((event, handler) => {
|
||||
if (event === "SIGTERM") sigTermHandler = handler as () => void;
|
||||
return process;
|
||||
});
|
||||
|
||||
await import("./server");
|
||||
|
||||
expect(sigTermHandler).toBeDefined();
|
||||
sigTermHandler!();
|
||||
expect(shutdown).toHaveBeenCalled();
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("shutdown handler logs error if shutdown rejects", async () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
|
||||
const shutdownError = new Error("shutdown failed");
|
||||
const { loggerError } = setupMocks({
|
||||
posthogKey: "phc_test_key",
|
||||
shutdown: vi.fn().mockRejectedValue(shutdownError),
|
||||
});
|
||||
|
||||
let sigTermHandler: (() => void) | undefined;
|
||||
const processOnSpy = vi.spyOn(process, "on").mockImplementation((event, handler) => {
|
||||
if (event === "SIGTERM") sigTermHandler = handler as () => void;
|
||||
return process;
|
||||
});
|
||||
|
||||
await import("./server");
|
||||
|
||||
sigTermHandler!();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(loggerError).toHaveBeenCalledWith(shutdownError, "Error shutting down PostHog server client");
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import "server-only";
|
||||
import { PostHog } from "posthog-node";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
|
||||
const POSTHOG_HOST = "https://eu.i.posthog.com";
|
||||
|
||||
const globalForPostHog = globalThis as unknown as {
|
||||
posthogServerClient: PostHog | undefined;
|
||||
posthogHandlersRegistered: boolean | undefined;
|
||||
};
|
||||
|
||||
function createPostHogClient(): PostHog | null {
|
||||
if (!POSTHOG_KEY) return null;
|
||||
|
||||
return new PostHog(POSTHOG_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export const posthogServerClient: PostHog | null =
|
||||
globalForPostHog.posthogServerClient ?? createPostHogClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production" && posthogServerClient) {
|
||||
globalForPostHog.posthogServerClient = posthogServerClient;
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === "nodejs" &&
|
||||
posthogServerClient &&
|
||||
!globalForPostHog.posthogHandlersRegistered
|
||||
) {
|
||||
const shutdownPostHog = () => {
|
||||
posthogServerClient?.shutdown().catch((err) => {
|
||||
logger.error(err, "Error shutting down PostHog server client");
|
||||
});
|
||||
};
|
||||
process.on("SIGTERM", shutdownPostHog);
|
||||
process.on("SIGINT", shutdownPostHog);
|
||||
globalForPostHog.posthogHandlersRegistered = true;
|
||||
}
|
||||
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
|
||||
|
||||
if (Array.isArray(responseValue)) {
|
||||
// Multiple choice case - response is an array of selected choice labels
|
||||
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
|
||||
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
|
||||
return responseValue
|
||||
.filter((v) => v !== "")
|
||||
.map(findChoiceByLabel)
|
||||
.filter((choiceId): choiceId is string => choiceId !== null);
|
||||
} else if (typeof responseValue === "string") {
|
||||
// Single choice case - response is a single choice label
|
||||
const choiceId = findChoiceByLabel(responseValue);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { truncateText } from "../utils/strings";
|
||||
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
|
||||
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
|
||||
let channels: TIntegrationItem[] = [];
|
||||
try {
|
||||
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
if (slackIntegration && slackIntegration.config?.key) {
|
||||
channels = await fetchChannels(slackIntegration);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
const getUserAuthenticationData = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserAuthenticationData(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
return await verifyPassword(password, user.password);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const publicUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
} as const satisfies Prisma.UserSelect;
|
||||
|
||||
export type TPublicUser = Prisma.UserGetPayload<{
|
||||
select: typeof publicUserSelect;
|
||||
}>;
|
||||
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { publicUserSelect } from "./public-user";
|
||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -47,11 +48,6 @@ describe("User Service", () => {
|
||||
locale: "en-US" as TUserLocale,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
};
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
@@ -102,8 +98,12 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
expect(result).not.toHaveProperty("password");
|
||||
expect(result).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result).not.toHaveProperty("backupCodes");
|
||||
expect(result).not.toHaveProperty("identityProviderAccountId");
|
||||
});
|
||||
|
||||
test("should return null when user not found", async () => {
|
||||
@@ -134,7 +134,7 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("User Service", () => {
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("User Service", () => {
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("User Service", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
};
|
||||
import { publicUserSelect } from "./public-user";
|
||||
|
||||
// function to retrive basic information about a user's user
|
||||
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return user;
|
||||
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
id: personId,
|
||||
},
|
||||
data: data,
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return users;
|
||||
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
|
||||
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
|
||||
import { getValidatedCallbackUrl, isStringUrl, testURLmatch } from "./url";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -72,23 +72,25 @@ describe("testURLmatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidCallbackUrl", () => {
|
||||
describe("getValidatedCallbackUrl", () => {
|
||||
const WEBAPP_URL = "https://webapp.example.com";
|
||||
|
||||
test("returns true for valid callback URL", () => {
|
||||
expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true);
|
||||
test("returns the normalized callback URL for a valid callback", () => {
|
||||
expect(getValidatedCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(
|
||||
"https://webapp.example.com/callback"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns false for invalid scheme", () => {
|
||||
expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false);
|
||||
test("returns null for invalid scheme", () => {
|
||||
expect(getValidatedCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns false for invalid domain", () => {
|
||||
expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false);
|
||||
test("returns null for invalid domain", () => {
|
||||
expect(getValidatedCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns false for malformed URL", () => {
|
||||
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
|
||||
test("returns null for malformed URL", () => {
|
||||
expect(getValidatedCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,18 +35,43 @@ export const testURLmatch = (
|
||||
};
|
||||
|
||||
// Helper function to validate callback URLs
|
||||
export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean => {
|
||||
export const getValidatedCallbackUrl = (
|
||||
url: string | null | undefined,
|
||||
WEBAPP_URL: string
|
||||
): string | null => {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const allowedSchemes = ["https:", "http:"];
|
||||
|
||||
// Extract the domain from WEBAPP_URL
|
||||
const parsedWebAppUrl = new URL(WEBAPP_URL);
|
||||
const allowedDomains = [parsedWebAppUrl.hostname];
|
||||
const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url);
|
||||
const isRootRelativePath = url.startsWith("/");
|
||||
|
||||
return allowedSchemes.includes(parsedUrl.protocol) && allowedDomains.includes(parsedUrl.hostname);
|
||||
} catch (err) {
|
||||
return false;
|
||||
// Reject ambiguous non-URL values like "foo" while still allowing safe root-relative paths.
|
||||
if (!isAbsoluteUrl && !isRootRelativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = isAbsoluteUrl ? new URL(url) : new URL(url, parsedWebAppUrl.origin);
|
||||
const allowedSchemes = ["https:", "http:"];
|
||||
const allowedOrigins = new Set([parsedWebAppUrl.origin]);
|
||||
|
||||
if (!allowedSchemes.includes(parsedUrl.protocol)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowedOrigins.has(parsedUrl.origin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsedUrl.username || parsedUrl.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Mit E-Mail einloggen",
|
||||
"lost_access": "Zugang verloren?",
|
||||
"new_to_formbricks": "Neu bei Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Einen Backup-Code verwenden"
|
||||
},
|
||||
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Unten rechts",
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"change_organization": "Organisation wechseln",
|
||||
"change_workspace": "Workspace wechseln",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Benachrichtigungen",
|
||||
"number": "Nummer",
|
||||
"off": "Aus",
|
||||
"offline_all_responses_synced": "Deine Antwort wurde erfolgreich gespeichert.",
|
||||
"offline_syncing_responses": "Deine Antworten werden synchronisiert…",
|
||||
"offline_you_are_offline": "Du bist offline. Deine Antwort ist in deinem Browser gespeichert und wird gesichert, sobald du wieder online bist.",
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
|
||||
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
|
||||
"reconnect_button": "Erneut verbinden",
|
||||
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "KI-Datenanalyse und -Anreicherung sind für diese Organisation deaktiviert.",
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
|
||||
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
|
||||
"ai_features_not_enabled_for_organization": "KI-Funktionen sind für diese Organisation nicht aktiviert.",
|
||||
"ai_instance_not_configured": "KI wird auf Instanzebene über Umgebungsvariablen konfiguriert. Bitte den Administrator, AI_PROVIDER, die Anmeldedaten für diesen Anbieter und die passende Modellliste zu konfigurieren, bevor du KI-Funktionen aktivierst.",
|
||||
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
|
||||
"ai_smart_tools_disabled_for_organization": "KI-Smart-Tools sind für diese Organisation deaktiviert.",
|
||||
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
|
||||
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
|
||||
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Nur der Besitzer kann die Organisation löschen.",
|
||||
"organization_created_successfully": "Organisation erfolgreich erstellt!",
|
||||
"organization_deleted_successfully": "Organisation erfolgreich gelöscht.",
|
||||
"organization_deletion_disabled": "Das Löschen von Organisationen ist deaktiviert.",
|
||||
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
|
||||
"organization_name": "Organisationsname",
|
||||
"organization_name_description": "Gib deiner Organisation einen Namen.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"update_personal_info": "Persönliche Daten aktualisieren",
|
||||
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"wrong_password": "Falsches Passwort"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Oberes Label",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"validate_id_duplicate": "{type}-ID existiert bereits in Fragen, versteckten Feldern oder Variablen.",
|
||||
"validate_id_empty": "Bitte gib eine {type}-ID ein.",
|
||||
"validate_id_invalid_chars": "{type}-ID ist nicht erlaubt. Bitte verwende nur alphanumerische Zeichen, Bindestriche oder Unterstriche.",
|
||||
"validate_id_no_spaces": "{type}-ID darf keine Leerzeichen enthalten. Bitte entferne die Leerzeichen.",
|
||||
"validate_id_reserved": "{type}-ID \"{field}\" ist nicht erlaubt. Es handelt sich um ein reserviertes Schlüsselwort.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Validierungsregel hinzufügen",
|
||||
"answer_all_rows": "Alle Zeilen beantworten",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
|
||||
"csat_question_1_lower_label": "Nicht wahrscheinlich",
|
||||
"csat_question_1_upper_label": "Sehr wahrscheinlich",
|
||||
"csat_question_2_choice_1": "Sehr zufrieden",
|
||||
"csat_question_2_choice_2": "Etwas zufrieden",
|
||||
"csat_question_2_choice_1": "Eher zufrieden",
|
||||
"csat_question_2_choice_2": "Sehr zufrieden",
|
||||
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
|
||||
"csat_question_2_choice_4": "Etwas unzufrieden",
|
||||
"csat_question_2_choice_5": "Sehr unzufrieden",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Login with Email",
|
||||
"lost_access": "Lost Access?",
|
||||
"new_to_formbricks": "New to Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Use a backup code"
|
||||
},
|
||||
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
|
||||
@@ -148,6 +150,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",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notifications",
|
||||
"number": "Number",
|
||||
"off": "Off",
|
||||
"offline_all_responses_synced": "Your response was saved successfully.",
|
||||
"offline_syncing_responses": "Syncing your responses…",
|
||||
"offline_you_are_offline": "You're offline. Your response is stored in your browser and will be saved when you're back online.",
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Send data to your Notion database",
|
||||
"please_select_a_survey_error": "Please select a survey",
|
||||
"reconnect_button": "Reconnect",
|
||||
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
|
||||
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
|
||||
"select_at_least_one_question_error": "Please select at least one question",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "You have already connected another survey to this channel.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "AI data analysis is disabled for this organization.",
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Manage AI-powered features for this organization.",
|
||||
"ai_features_not_enabled_for_organization": "AI features are not enabled for this organization.",
|
||||
"ai_instance_not_configured": "AI is configured at the instance level via environment variables. Ask your administrator to set AI_PROVIDER, AI_MODEL, and the matching provider credentials before enabling AI features.",
|
||||
"ai_settings_updated_successfully": "AI settings updated successfully",
|
||||
"ai_smart_tools_disabled_for_organization": "AI smart tools are disabled for this organization.",
|
||||
"ai_smart_tools_enabled": "Smart functionality (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
|
||||
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Only organization owners can access this setting.",
|
||||
"organization_created_successfully": "Organization created successfully",
|
||||
"organization_deleted_successfully": "Organization deleted successfully",
|
||||
"organization_deletion_disabled": "Organization deletion is disabled.",
|
||||
"organization_invite_link_ready": "Your organization invite link is ready!",
|
||||
"organization_name": "Organization Name",
|
||||
"organization_name_description": "Give your organization a descriptive name.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"update_personal_info": "Update your personal information",
|
||||
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
"warning_cannot_undo": "This cannot be undone",
|
||||
"wrong_password": "Wrong password"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Add members to the team and determine their role.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Upper Label",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"validate_id_duplicate": "{type} ID already exists in questions, hidden fields, or variables.",
|
||||
"validate_id_empty": "Please enter a {type} ID.",
|
||||
"validate_id_invalid_chars": "{type} ID is not allowed. Please use only alphanumeric characters, hyphens, or underscores.",
|
||||
"validate_id_no_spaces": "{type} ID cannot contain spaces. Please remove spaces.",
|
||||
"validate_id_reserved": "{type} ID \"{field}\" is not allowed. It is a reserved keyword.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Add validation rule",
|
||||
"answer_all_rows": "Answer all rows",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_survey_tooltip": "Average time to complete the survey.",
|
||||
"ttc_tooltip": "Average time to complete the question.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"use_personal_links": "Use personal links",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
|
||||
"csat_question_1_lower_label": "Not likely",
|
||||
"csat_question_1_upper_label": "Very likely",
|
||||
"csat_question_2_choice_1": "Very satisfied",
|
||||
"csat_question_2_choice_2": "Somewhat satisfied",
|
||||
"csat_question_2_choice_1": "Somewhat satisfied",
|
||||
"csat_question_2_choice_2": "Very satisfied",
|
||||
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
|
||||
"csat_question_2_choice_4": "Somewhat dissatisfied",
|
||||
"csat_question_2_choice_5": "Very dissatisfied",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Iniciar sesión con correo electrónico",
|
||||
"lost_access": "¿Has perdido el acceso?",
|
||||
"new_to_formbricks": "¿Nuevo en Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Usar un código de respaldo"
|
||||
},
|
||||
"saml_connection_error": "Algo salió mal. Por favor, consulta la consola de tu aplicación para más detalles.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Inferior derecha",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal centrado",
|
||||
"change_organization": "Cambiar organización",
|
||||
"change_workspace": "Cambiar espacio de trabajo",
|
||||
"choices": "Opciones",
|
||||
"choose_environment": "Elegir entorno",
|
||||
"choose_organization": "Elegir organización",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notificaciones",
|
||||
"number": "Número",
|
||||
"off": "Desactivado",
|
||||
"offline_all_responses_synced": "Tu respuesta se guardó correctamente.",
|
||||
"offline_syncing_responses": "Sincronizando tus respuestas…",
|
||||
"offline_you_are_offline": "Estás sin conexión. Tu respuesta está almacenada en tu navegador y se guardará cuando vuelvas a estar en línea.",
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_role": "Rol del equipo",
|
||||
"teams": "Equipos",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"text": "Texto",
|
||||
"time": "Hora",
|
||||
"time_to_finish": "Tiempo para finalizar",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Envía datos a tu base de datos de Notion",
|
||||
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
|
||||
"reconnect_button": "Reconectar",
|
||||
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
|
||||
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
|
||||
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "El análisis y enriquecimiento de datos con IA está deshabilitado para esta organización.",
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
|
||||
"ai_enabled": "IA de Formbricks",
|
||||
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
|
||||
"ai_features_not_enabled_for_organization": "Las funciones de IA no están habilitadas para esta organización.",
|
||||
"ai_instance_not_configured": "La IA se configura a nivel de instancia mediante variables de entorno. Pide a tu administrador que configure AI_PROVIDER, las credenciales de ese proveedor y la lista de modelos correspondiente antes de habilitar las funciones de IA.",
|
||||
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
|
||||
"ai_smart_tools_disabled_for_organization": "Las herramientas inteligentes de IA están deshabilitadas para esta organización.",
|
||||
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
|
||||
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Solo los propietarios de la organización pueden acceder a esta configuración.",
|
||||
"organization_created_successfully": "¡Organización creada correctamente!",
|
||||
"organization_deleted_successfully": "Organización eliminada correctamente.",
|
||||
"organization_deletion_disabled": "La eliminación de organizaciones está deshabilitada.",
|
||||
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
|
||||
"organization_name": "Nombre de la organización",
|
||||
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
|
||||
"update_personal_info": "Actualiza tu información personal",
|
||||
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
|
||||
"warning_cannot_undo": "Esto no se puede deshacer"
|
||||
"warning_cannot_undo": "Esto no se puede deshacer",
|
||||
"wrong_password": "Contraseña incorrecta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Añade miembros al equipo y determina su rol.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Etiqueta superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL no compatible",
|
||||
"validate_id_duplicate": "El ID de {type} ya existe en preguntas, campos ocultos o variables.",
|
||||
"validate_id_empty": "Por favor, introduce un ID de {type}.",
|
||||
"validate_id_invalid_chars": "El ID de {type} no está permitido. Por favor, utiliza solo caracteres alfanuméricos, guiones o guiones bajos.",
|
||||
"validate_id_no_spaces": "El ID de {type} no puede contener espacios. Por favor, elimina los espacios.",
|
||||
"validate_id_reserved": "El ID de {type} \"{field}\" no está permitido. Es una palabra reservada.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Añadir regla de validación",
|
||||
"answer_all_rows": "Responde todas las filas",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este año",
|
||||
"time_to_complete": "Tiempo para completar",
|
||||
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
|
||||
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
|
||||
"unknown_question_type": "Tipo de pregunta desconocido",
|
||||
"use_personal_links": "Usar enlaces personales",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
|
||||
"csat_question_1_lower_label": "Poco probable",
|
||||
"csat_question_1_upper_label": "Muy probable",
|
||||
"csat_question_2_choice_1": "Muy satisfecho",
|
||||
"csat_question_2_choice_2": "Algo satisfecho",
|
||||
"csat_question_2_choice_1": "Algo satisfecho",
|
||||
"csat_question_2_choice_2": "Muy satisfecho",
|
||||
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
|
||||
"csat_question_2_choice_4": "Algo insatisfecho",
|
||||
"csat_question_2_choice_5": "Muy insatisfecho",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Se connecter avec un e-mail",
|
||||
"lost_access": "Accès perdu ?",
|
||||
"new_to_formbricks": "Nouveau sur Formbricks ?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Utiliser un code de secours"
|
||||
},
|
||||
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "En bas à droite",
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Au centre",
|
||||
"change_organization": "Changer d'organisation",
|
||||
"change_workspace": "Changer d'espace de travail",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notifications",
|
||||
"number": "Numéro",
|
||||
"off": "Éteint",
|
||||
"offline_all_responses_synced": "Ta réponse a été enregistrée avec succès.",
|
||||
"offline_syncing_responses": "Synchronisation de vos réponses…",
|
||||
"offline_you_are_offline": "Tu es hors ligne. Ta réponse est stockée dans ton navigateur et sera enregistrée dès que tu seras de nouveau en ligne.",
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_role": "Rôle dans l'équipe",
|
||||
"teams": "Équipes",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"text": "Texte",
|
||||
"time": "Temps",
|
||||
"time_to_finish": "Temps de finir",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "Changer le mot de passe",
|
||||
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
|
||||
"forgot_password_email_heading": "Changer le mot de passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant {minutes} minutes.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valide pendant {minutes} minutes.",
|
||||
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
|
||||
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
|
||||
"hidden_field": "Champ caché",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
|
||||
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
|
||||
"reconnect_button": "Reconnecter",
|
||||
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
|
||||
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
|
||||
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "L'analyse et l'enrichissement des données par IA sont désactivés pour cette organisation.",
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
|
||||
"ai_enabled": "IA Formbricks",
|
||||
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
|
||||
"ai_features_not_enabled_for_organization": "Les fonctionnalités d'IA ne sont pas activées pour cette organisation.",
|
||||
"ai_instance_not_configured": "L'IA est configurée au niveau de l'instance via des variables d'environnement. Demandez à votre administrateur de définir AI_PROVIDER, les identifiants du fournisseur et la liste de modèles correspondante avant d'activer les fonctionnalités d'IA.",
|
||||
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
|
||||
"ai_smart_tools_disabled_for_organization": "Les outils intelligents d'IA sont désactivés pour cette organisation.",
|
||||
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
|
||||
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Seules les personnes ayant un rôle d'administrateur dans l'organisation peuvent accéder à ce paramètre.",
|
||||
"organization_created_successfully": "Organisation créée avec succès !",
|
||||
"organization_deleted_successfully": "Organisation supprimée avec succès.",
|
||||
"organization_deletion_disabled": "La suppression des organisations est désactivée.",
|
||||
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
|
||||
"organization_name": "Nom de l'organisation",
|
||||
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"update_personal_info": "Mettez à jour vos informations personnelles",
|
||||
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
|
||||
"warning_cannot_undo": "Cette opération est irréversible."
|
||||
"warning_cannot_undo": "Cette opération est irréversible.",
|
||||
"wrong_password": "Mot de passe incorrect"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"validate_id_duplicate": "L'ID {type} existe déjà dans les questions, champs masqués ou variables.",
|
||||
"validate_id_empty": "Veuillez saisir un ID {type}.",
|
||||
"validate_id_invalid_chars": "L'ID {type} n'est pas autorisé. Veuillez utiliser uniquement des caractères alphanumériques, des traits d'union ou des underscores.",
|
||||
"validate_id_no_spaces": "L'ID {type} ne peut pas contenir d'espaces. Veuillez supprimer les espaces.",
|
||||
"validate_id_reserved": "L'ID {type} \"{field}\" n'est pas autorisé. C'est un mot-clé réservé.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Ajouter une règle de validation",
|
||||
"answer_all_rows": "Répondre à toutes les lignes",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
|
||||
"ttc_tooltip": "Temps moyen pour compléter la question.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Quelle est la probabilité que vous recommandiez ce $[projectName] à un ami ou un collègue ?",
|
||||
"csat_question_1_lower_label": "Peu probable",
|
||||
"csat_question_1_upper_label": "Très probable",
|
||||
"csat_question_2_choice_1": "Très satisfait",
|
||||
"csat_question_2_choice_2": "Un peu satisfait",
|
||||
"csat_question_2_choice_1": "Plutôt satisfait",
|
||||
"csat_question_2_choice_2": "Très satisfait",
|
||||
"csat_question_2_choice_3": "Ni satisfait ni insatisfait",
|
||||
"csat_question_2_choice_4": "Un peu insatisfait",
|
||||
"csat_question_2_choice_5": "Très insatisfait",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Bejelentkezés e-mail-címmel",
|
||||
"lost_access": "Elvesztette a hozzáférést?",
|
||||
"new_to_formbricks": "Új a Formbicksen?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Visszaszerzési kód használata"
|
||||
},
|
||||
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Jobbra lent",
|
||||
"cancel": "Mégse",
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"change_organization": "Szervezet módosítása",
|
||||
"change_workspace": "Munkaterület módosítása",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_environment": "Környezet kiválasztása",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Értesítések",
|
||||
"number": "Szám",
|
||||
"off": "Ki",
|
||||
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
|
||||
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
|
||||
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_role": "Csapatszerep",
|
||||
"teams": "Csapatok",
|
||||
"terms_of_service": "Felhasználási feltételek",
|
||||
"text": "Szöveg",
|
||||
"time": "Idő",
|
||||
"time_to_finish": "Idő a befejezésig",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
|
||||
"please_select_a_survey_error": "Válasszon kérdőívet",
|
||||
"reconnect_button": "Újracsatlakozás",
|
||||
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
|
||||
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
|
||||
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
|
||||
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
|
||||
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
|
||||
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
|
||||
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
|
||||
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
|
||||
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Csak a szervezet tulajdonosai férhetnek hozzá ehhez a beállításhoz.",
|
||||
"organization_created_successfully": "A szervezet sikeresen létrehozva",
|
||||
"organization_deleted_successfully": "A szervezet sikeresen törölve",
|
||||
"organization_deletion_disabled": "A szervezet törlése le van tiltva.",
|
||||
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
|
||||
"organization_name": "Szervezet neve",
|
||||
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
|
||||
"update_personal_info": "Személyes információk frissítése",
|
||||
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni"
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni",
|
||||
"wrong_password": "Hibás jelszó"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Felső címke",
|
||||
"url_filters": "URL szűrők",
|
||||
"url_not_supported": "Az URL nem támogatott",
|
||||
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
|
||||
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
|
||||
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
|
||||
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
|
||||
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
|
||||
"answer_all_rows": "Válaszoljon az összes sorra",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Ez a negyedév",
|
||||
"this_year": "Ez az év",
|
||||
"time_to_complete": "Kitöltéshez szükséges idő",
|
||||
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
|
||||
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
|
||||
"unknown_question_type": "Ismeretlen kérdéstípus",
|
||||
"use_personal_links": "Személyes hivatkozások használata",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
|
||||
"csat_question_1_lower_label": "Nem valószínű",
|
||||
"csat_question_1_upper_label": "Nagyon valószínű",
|
||||
"csat_question_2_choice_1": "Nagyon elégedett",
|
||||
"csat_question_2_choice_2": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_1": "Részben elégedett",
|
||||
"csat_question_2_choice_2": "Nagyon elégedett",
|
||||
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
|
||||
"csat_question_2_choice_4": "Valamelyest elégedetlen",
|
||||
"csat_question_2_choice_5": "Nagyon elégedetlen",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "メールアドレスでログイン",
|
||||
"lost_access": "アクセスを紛失しましたか?",
|
||||
"new_to_formbricks": "Formbricksを初めてご利用ですか?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "バックアップコードを使用"
|
||||
},
|
||||
"saml_connection_error": "問題が発生しました。詳細については、アプリのコンソールを確認してください。",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "キャンセル",
|
||||
"centered_modal": "中央モーダル",
|
||||
"change_organization": "組織を変更",
|
||||
"change_workspace": "ワークスペースを変更",
|
||||
"choices": "選択肢",
|
||||
"choose_environment": "環境を選択",
|
||||
"choose_organization": "組織を選択",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "通知",
|
||||
"number": "数値",
|
||||
"off": "オフ",
|
||||
"offline_all_responses_synced": "回答が正常に保存されました。",
|
||||
"offline_syncing_responses": "回答を同期中…",
|
||||
"offline_you_are_offline": "オフラインです。回答はブラウザに保存されており、オンラインに戻ると保存されます。",
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"terms_of_service": "利用規約",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
"time_to_finish": "所要時間",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "パスワードを変更",
|
||||
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
|
||||
"forgot_password_email_heading": "パスワードを変更",
|
||||
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "リンクの有効期限は{minutes}分です。",
|
||||
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
|
||||
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
|
||||
"hidden_field": "非表示フィールド",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "回答を直接Notionに送信します",
|
||||
"please_select_a_survey_error": "フォームを選択してください",
|
||||
"reconnect_button": "再接続",
|
||||
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
|
||||
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
|
||||
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "この組織では AI によるデータ分析と拡張が無効になっています。",
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "この組織のAI機能を管理します。",
|
||||
"ai_features_not_enabled_for_organization": "この組織では AI 機能が有効になっていません。",
|
||||
"ai_instance_not_configured": "AI は環境変数を使ってインスタンスレベルで設定されます。AI 機能を有効にする前に、管理者に AI_PROVIDER、このプロバイダーの認証情報、および対応するモデル一覧を設定してもらってください。",
|
||||
"ai_settings_updated_successfully": "AI設定が正常に更新されました",
|
||||
"ai_smart_tools_disabled_for_organization": "この組織では AI スマートツールが無効になっています。",
|
||||
"ai_smart_tools_enabled": "スマート機能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AIを活用して、より短時間でより多くのことを達成できます。Formbricksで収集されたデータには一切触れません。アンケートを他の言語に翻訳するなどの用途にのみ使用されます。",
|
||||
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "組織のオーナーのみがこの設定にアクセスできます。",
|
||||
"organization_created_successfully": "組織を正常に作成しました!",
|
||||
"organization_deleted_successfully": "組織を正常に削除しました。",
|
||||
"organization_deletion_disabled": "組織の削除は無効になっています。",
|
||||
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
|
||||
"organization_name": "組織名",
|
||||
"organization_name_description": "組織に分かりやすい名前を付けます。",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
|
||||
"update_personal_info": "個人情報を更新",
|
||||
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
|
||||
"warning_cannot_undo": "この操作は元に戻せません"
|
||||
"warning_cannot_undo": "この操作は元に戻せません",
|
||||
"wrong_password": "パスワードが間違っています"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
|
||||
@@ -1662,7 +1679,7 @@
|
||||
"quota_name_placeholder": "例: 年齢 18 から 25 歳 の 参加者",
|
||||
"quota_updated_successfull_toast": "クオータを更新しました",
|
||||
"response_limit": "制限",
|
||||
"save_changes_confirmation_body": "今後の回答のみに影響します。\\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
|
||||
"save_changes_confirmation_body": "今後の回答のみに影響します。\n 既存のクォータを複製するか、新しいクォータを作成することをお勧めします。",
|
||||
"save_changes_confirmation_text": "既存の応答 は クォータ に とどまります",
|
||||
"select_ending_card": "終了カードを選択",
|
||||
"upgrade_prompt_title": "上位プランで クォータ を使用",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "上限ラベル",
|
||||
"url_filters": "URLフィルター",
|
||||
"url_not_supported": "URLはサポートされていません",
|
||||
"validate_id_duplicate": "{type} IDは質問、非表示フィールド、または変数に既に存在します。",
|
||||
"validate_id_empty": "{type} IDを入力してください。",
|
||||
"validate_id_invalid_chars": "{type} IDは使用できません。英数字、ハイフン、アンダースコアのみを使用してください。",
|
||||
"validate_id_no_spaces": "{type} IDにスペースを含めることはできません。スペースを削除してください。",
|
||||
"validate_id_reserved": "{type} ID \"{field}\" は使用できません。予約されたキーワードです。",
|
||||
"validation": {
|
||||
"add_validation_rule": "検証ルールを追加",
|
||||
"answer_all_rows": "すべての行に回答してください",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "今四半期",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完了までの時間",
|
||||
"ttc_survey_tooltip": "アンケートの平均完了時間。",
|
||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||
"unknown_question_type": "不明な質問の種類",
|
||||
"use_personal_links": "個人リンクを使用",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
|
||||
"csat_question_1_lower_label": "可能性が低い",
|
||||
"csat_question_1_upper_label": "可能性が非常に高い",
|
||||
"csat_question_2_choice_1": "非常に満足",
|
||||
"csat_question_2_choice_2": "やや満足",
|
||||
"csat_question_2_choice_1": "やや満足している",
|
||||
"csat_question_2_choice_2": "とても満足している",
|
||||
"csat_question_2_choice_3": "満足も不満もない",
|
||||
"csat_question_2_choice_4": "やや不満",
|
||||
"csat_question_2_choice_5": "非常に不満",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Inloggen met e-mail",
|
||||
"lost_access": "Toegang verloren?",
|
||||
"new_to_formbricks": "Nieuw bij Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Gebruik een back-upcode"
|
||||
},
|
||||
"saml_connection_error": "Er is iets misgegaan. Controleer uw app-console voor meer details.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Rechtsonder",
|
||||
"cancel": "Annuleren",
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"change_organization": "Organisatie wijzigen",
|
||||
"change_workspace": "Werkruimte wijzigen",
|
||||
"choices": "Keuzes",
|
||||
"choose_environment": "Kies omgeving",
|
||||
"choose_organization": "Kies organisatie",
|
||||
@@ -252,7 +256,7 @@
|
||||
"images": "Afbeeldingen",
|
||||
"import": "Importeren",
|
||||
"impressions": "Indrukken",
|
||||
"imprint": "Afdruk",
|
||||
"imprint": "Wettelijke vermeldingen",
|
||||
"in_progress": "In uitvoering",
|
||||
"inactive_surveys": "Inactieve enquêtes",
|
||||
"integration": "integratie",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Meldingen",
|
||||
"number": "Nummer",
|
||||
"off": "Uit",
|
||||
"offline_all_responses_synced": "Je reactie is succesvol opgeslagen.",
|
||||
"offline_syncing_responses": "Je antwoorden synchroniseren…",
|
||||
"offline_you_are_offline": "Je bent offline. Je reactie is opgeslagen in je browser en wordt bewaard zodra je weer online bent.",
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
@@ -366,7 +373,7 @@
|
||||
"remove_from_team": "Verwijderen uit team",
|
||||
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
|
||||
"replace": "Vervangen",
|
||||
"report_survey": "Verslag enquête",
|
||||
"report_survey": "Enquête melden",
|
||||
"request_trial_license": "Proeflicentie aanvragen",
|
||||
"reset_to_default": "Resetten naar standaard",
|
||||
"response": "Antwoord",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_role": "Teamrol",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Gebruiksvoorwaarden",
|
||||
"text": "Tekst",
|
||||
"time": "Tijd",
|
||||
"time_to_finish": "Tijd om af te ronden",
|
||||
@@ -509,7 +517,7 @@
|
||||
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
|
||||
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
|
||||
"hidden_field": "Verborgen veld",
|
||||
"imprint": "Afdruk",
|
||||
"imprint": "Wettelijke vermeldingen",
|
||||
"invite_accepted_email_heading": "Hé {inviterName}",
|
||||
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
|
||||
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
|
||||
"please_select_a_survey_error": "Selecteer een enquête",
|
||||
"reconnect_button": "Opnieuw verbinden",
|
||||
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
|
||||
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
|
||||
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "AI-gegevensverrijking en -analyse is uitgeschakeld voor deze organisatie.",
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
|
||||
"ai_features_not_enabled_for_organization": "AI-functies zijn niet ingeschakeld voor deze organisatie.",
|
||||
"ai_instance_not_configured": "AI wordt op instantieniveau geconfigureerd via omgevingsvariabelen. Vraag je beheerder om AI_PROVIDER, de inloggegevens voor die provider en de bijbehorende modellenlijst in te stellen voordat AI-functies worden ingeschakeld.",
|
||||
"ai_settings_updated_successfully": "AI-instellingen succesvol bijgewerkt",
|
||||
"ai_smart_tools_disabled_for_organization": "AI-slimme functies zijn uitgeschakeld voor deze organisatie.",
|
||||
"ai_smart_tools_enabled": "Slimme functionaliteit (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI om je te helpen meer te bereiken in minder tijd. Raakt nooit data aan die met Formbricks is verzameld. Wordt alleen gebruikt om bijvoorbeeld enquêtes naar andere talen te vertalen.",
|
||||
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Alleen organisatie-eigenaren hebben toegang tot deze instelling.",
|
||||
"organization_created_successfully": "Organisatie succesvol aangemaakt!",
|
||||
"organization_deleted_successfully": "Organisatie succesvol verwijderd.",
|
||||
"organization_deletion_disabled": "Het verwijderen van organisaties is uitgeschakeld.",
|
||||
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
|
||||
"organization_name": "Organisatienaam",
|
||||
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
|
||||
"update_personal_info": "Update uw persoonlijke gegevens",
|
||||
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
|
||||
"wrong_password": "Verkeerd wachtwoord"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Bovenste etiket",
|
||||
"url_filters": "URL-filters",
|
||||
"url_not_supported": "URL niet ondersteund",
|
||||
"validate_id_duplicate": "{type}-ID bestaat al in vragen, verborgen velden of variabelen.",
|
||||
"validate_id_empty": "Voer een {type}-ID in.",
|
||||
"validate_id_invalid_chars": "{type}-ID is niet toegestaan. Gebruik alleen alfanumerieke tekens, koppeltekens of underscores.",
|
||||
"validate_id_no_spaces": "{type}-ID mag geen spaties bevatten. Verwijder de spaties.",
|
||||
"validate_id_reserved": "{type}-ID \"{field}\" is niet toegestaan. Dit is een gereserveerd trefwoord.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Validatieregel toevoegen",
|
||||
"answer_all_rows": "Beantwoord alle rijen",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Dit kwartaal",
|
||||
"this_year": "Dit jaar",
|
||||
"time_to_complete": "Tijd om te voltooien",
|
||||
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
|
||||
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
|
||||
"unknown_question_type": "Onbekend vraagtype",
|
||||
"use_personal_links": "Gebruik persoonlijke links",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
|
||||
"csat_question_1_lower_label": "Niet waarschijnlijk",
|
||||
"csat_question_1_upper_label": "Zeer waarschijnlijk",
|
||||
"csat_question_2_choice_1": "Zeer tevreden",
|
||||
"csat_question_2_choice_2": "Enigszins tevreden",
|
||||
"csat_question_2_choice_1": "Redelijk tevreden",
|
||||
"csat_question_2_choice_2": "Zeer tevreden",
|
||||
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
|
||||
"csat_question_2_choice_4": "Enigszins ontevreden",
|
||||
"csat_question_2_choice_5": "Zeer ontevreden",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Entrar com Email",
|
||||
"lost_access": "Perdeu o acesso?",
|
||||
"new_to_formbricks": "Novo no Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Usar um código de backup"
|
||||
},
|
||||
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Canto Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolher ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
"off": "desligado",
|
||||
"offline_all_responses_synced": "Sua resposta foi salva com sucesso.",
|
||||
"offline_syncing_responses": "Sincronizando suas respostas…",
|
||||
"offline_you_are_offline": "Você está offline. Sua resposta está armazenada no seu navegador e será salva quando você voltar a ficar online.",
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_role": "Função na equipe",
|
||||
"teams": "Equipes",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
"time_to_finish": "Hora de terminar",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
|
||||
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
|
||||
"reconnect_button": "Reconectar",
|
||||
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
|
||||
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
|
||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "A análise e o enriquecimento de dados com IA estão desativados para esta organização.",
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
|
||||
"ai_features_not_enabled_for_organization": "Os recursos de IA não estão habilitados para esta organização.",
|
||||
"ai_instance_not_configured": "A IA é configurada no nível da instância por meio de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse provedor e a lista de modelos correspondente antes de habilitar os recursos de IA.",
|
||||
"ai_settings_updated_successfully": "Configurações de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_disabled_for_organization": "As funcionalidades inteligentes de IA estão desativadas para esta organização.",
|
||||
"ai_smart_tools_enabled": "Funcionalidades inteligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ajudar você a conquistar mais em menos tempo. Nunca acessa dados coletados com o Formbricks. Usado apenas para, por exemplo, traduzir pesquisas para outros idiomas.",
|
||||
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Somente o Dono pode deletar a organização.",
|
||||
"organization_created_successfully": "Organização criada com sucesso!",
|
||||
"organization_deleted_successfully": "Organização deletada com sucesso.",
|
||||
"organization_deletion_disabled": "A exclusão de organizações está desativada.",
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê um nome descritivo pra sua organização.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"update_personal_info": "Atualize suas informações pessoais",
|
||||
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito",
|
||||
"wrong_password": "Senha incorreta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicione membros à equipe e determine sua função.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"validate_id_duplicate": "O ID de {type} já existe em perguntas, campos ocultos ou variáveis.",
|
||||
"validate_id_empty": "Por favor, insira um ID de {type}.",
|
||||
"validate_id_invalid_chars": "O ID de {type} não é permitido. Por favor, use apenas caracteres alfanuméricos, hífens ou underscores.",
|
||||
"validate_id_no_spaces": "O ID de {type} não pode conter espaços. Por favor, remova os espaços.",
|
||||
"validate_id_reserved": "O ID de {type} \"{field}\" não é permitido. É uma palavra reservada.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adicionar regra de validação",
|
||||
"answer_all_rows": "Responda todas as linhas",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"ttc_tooltip": "Tempo médio para completar a pergunta.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Meio satisfeito",
|
||||
"csat_question_2_choice_1": "Um pouco satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Um pouco insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Iniciar sessão com Email",
|
||||
"lost_access": "Perdeu o acesso?",
|
||||
"new_to_formbricks": "Novo no Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Use um código de backup"
|
||||
},
|
||||
"saml_connection_error": "Algo correu mal. Por favor, verifique a consola da aplicação para mais detalhes.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"change_organization": "Alterar organização",
|
||||
"change_workspace": "Alterar espaço de trabalho",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolha o ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notificações",
|
||||
"number": "Número",
|
||||
"off": "Desligado",
|
||||
"offline_all_responses_synced": "A tua resposta foi guardada com sucesso.",
|
||||
"offline_syncing_responses": "A sincronizar as tuas respostas…",
|
||||
"offline_you_are_offline": "Estás offline. A tua resposta está armazenada no teu navegador e será guardada quando voltares a estar online.",
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_role": "Função na equipa",
|
||||
"teams": "Equipas",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "Tempo",
|
||||
"time_to_finish": "Tempo para concluir",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "Alterar palavra-passe",
|
||||
"forgot_password_email_did_not_request": "Se não solicitou isto, por favor ignore este email.",
|
||||
"forgot_password_email_heading": "Alterar palavra-passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "A ligação é válida durante {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
|
||||
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
|
||||
"please_select_a_survey_error": "Por favor, selecione um inquérito",
|
||||
"reconnect_button": "Voltar a ligar",
|
||||
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
|
||||
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
|
||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "A análise e o enriquecimento de dados com IA estão desativados para esta organização.",
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
|
||||
"ai_enabled": "IA da Formbricks",
|
||||
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
|
||||
"ai_features_not_enabled_for_organization": "As funcionalidades de IA não estão ativadas para esta organização.",
|
||||
"ai_instance_not_configured": "A IA é configurada ao nível da instância através de variáveis de ambiente. Peça ao seu administrador para definir AI_PROVIDER, as credenciais desse fornecedor e a lista de modelos correspondente antes de ativar as funcionalidades de IA.",
|
||||
"ai_settings_updated_successfully": "Definições de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_disabled_for_organization": "As funcionalidades inteligentes de IA estão desativadas para esta organização.",
|
||||
"ai_smart_tools_enabled": "Funcionalidade inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para te ajudar a alcançar mais em menos tempo. Nunca acede aos dados recolhidos com o Formbricks. Apenas usado para, por exemplo, traduzir inquéritos para outros idiomas.",
|
||||
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Apenas os proprietários da organização podem aceder a esta configuração.",
|
||||
"organization_created_successfully": "Organização criada com sucesso!",
|
||||
"organization_deleted_successfully": "Organização eliminada com sucesso.",
|
||||
"organization_deletion_disabled": "A eliminação de organizações está desativada.",
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê à sua organização um nome descritivo.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"update_personal_info": "Atualize as suas informações pessoais",
|
||||
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito",
|
||||
"wrong_password": "Palavra-passe incorreta"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"validate_id_duplicate": "O ID {type} já existe em perguntas, campos ocultos ou variáveis.",
|
||||
"validate_id_empty": "Por favor, introduza um ID {type}.",
|
||||
"validate_id_invalid_chars": "O ID {type} não é permitido. Por favor, utilize apenas caracteres alfanuméricos, hífenes ou underscores.",
|
||||
"validate_id_no_spaces": "O ID {type} não pode conter espaços. Por favor, remova os espaços.",
|
||||
"validate_id_reserved": "O ID {type} \"{field}\" não é permitido. É uma palavra reservada.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adicionar regra de validação",
|
||||
"answer_all_rows": "Responda a todas as linhas",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
|
||||
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de recomendar este $[projectName] a um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Algo satisfeito",
|
||||
"csat_question_2_choice_1": "Algo satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Algo insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Autentifică-te cu email",
|
||||
"lost_access": "Acces pierdut?",
|
||||
"new_to_formbricks": "Nou în Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Folosiți un cod de rezervă"
|
||||
},
|
||||
"saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Dreapta Jos",
|
||||
"cancel": "Anulare",
|
||||
"centered_modal": "Modală centralizată",
|
||||
"change_organization": "Schimbă organizația",
|
||||
"change_workspace": "Schimbă spațiul de lucru",
|
||||
"choices": "Alegeri",
|
||||
"choose_environment": "Alege mediul",
|
||||
"choose_organization": "Alege organizația",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Notificări",
|
||||
"number": "Număr",
|
||||
"off": "Oprit",
|
||||
"offline_all_responses_synced": "Răspunsul tău a fost salvat cu succes.",
|
||||
"offline_syncing_responses": "Se sincronizează răspunsurile tale…",
|
||||
"offline_you_are_offline": "Ești offline. Răspunsul tău este stocat în browser și va fi salvat când vei fi din nou online.",
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_role": "Rol în echipă",
|
||||
"teams": "Echipe",
|
||||
"terms_of_service": "Termeni și condiții",
|
||||
"text": "Text",
|
||||
"time": "Timp",
|
||||
"time_to_finish": "Timp până la finalizare",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "Schimbați parola",
|
||||
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
|
||||
"forgot_password_email_heading": "Schimbați parola",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de {minutes} minute.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil {minutes} minute.",
|
||||
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
|
||||
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Trimiteți datele în baza de date Notion",
|
||||
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
|
||||
"reconnect_button": "Reconectează",
|
||||
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
|
||||
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
|
||||
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "Analiza și îmbogățirea datelor cu AI sunt dezactivate pentru această organizație.",
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
|
||||
"ai_features_not_enabled_for_organization": "Funcționalitățile AI nu sunt activate pentru această organizație.",
|
||||
"ai_instance_not_configured": "AI este configurată la nivel de instanță prin variabile de mediu. Cere administratorului să configureze AI_PROVIDER, credențialele acelui furnizor și lista de modele corespunzătoare înainte de a activa funcționalitățile AI.",
|
||||
"ai_settings_updated_successfully": "Setările AI au fost actualizate cu succes",
|
||||
"ai_smart_tools_disabled_for_organization": "Instrumentele inteligente AI sunt dezactivate pentru această organizație.",
|
||||
"ai_smart_tools_enabled": "Funcționalitate inteligentă (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI care te ajută să faci mai mult în mai puțin timp. Nu accesează niciodată datele colectate cu Formbricks. Folosit doar, de exemplu, pentru a traduce chestionare în alte limbi.",
|
||||
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.",
|
||||
"organization_created_successfully": "Organizație creată cu succes!",
|
||||
"organization_deleted_successfully": "Organizație ștearsă cu succes!",
|
||||
"organization_deletion_disabled": "Ștergerea organizațiilor este dezactivată.",
|
||||
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
|
||||
"organization_name": "Nume Organizație",
|
||||
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"update_personal_info": "Actualizează informațiile tale personale",
|
||||
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată",
|
||||
"wrong_password": "Parolă greșită"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Etichetă superioară",
|
||||
"url_filters": "Filtre URL",
|
||||
"url_not_supported": "URL nesuportat",
|
||||
"validate_id_duplicate": "ID-ul {type} există deja în întrebări, câmpuri ascunse sau variabile.",
|
||||
"validate_id_empty": "Te rugăm să introduci un ID {type}.",
|
||||
"validate_id_invalid_chars": "ID-ul {type} nu este permis. Te rugăm să folosești doar caractere alfanumerice, cratimă sau liniuță de subliniere.",
|
||||
"validate_id_no_spaces": "ID-ul {type} nu poate conține spații. Te rugăm să elimini spațiile.",
|
||||
"validate_id_reserved": "ID-ul {type} \"{field}\" nu este permis. Este un cuvânt cheie rezervat.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adaugă regulă de validare",
|
||||
"answer_all_rows": "Răspunde la toate rândurile",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Trimestrul acesta",
|
||||
"this_year": "Anul acesta",
|
||||
"time_to_complete": "Timp de finalizare",
|
||||
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
|
||||
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
||||
"unknown_question_type": "Tip de întrebare necunoscut",
|
||||
"use_personal_links": "Folosește linkuri personale",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
|
||||
"csat_question_1_lower_label": "Puțin probabil",
|
||||
"csat_question_1_upper_label": "Foarte probabil",
|
||||
"csat_question_2_choice_1": "Foarte mulțumit",
|
||||
"csat_question_2_choice_2": "Puțin mulțumit",
|
||||
"csat_question_2_choice_1": "Oarecum mulțumit",
|
||||
"csat_question_2_choice_2": "Foarte mulțumit",
|
||||
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
|
||||
"csat_question_2_choice_4": "Ușor nemulțumit",
|
||||
"csat_question_2_choice_5": "Foarte nemulțumit",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Войти по email",
|
||||
"lost_access": "Потеряли доступ?",
|
||||
"new_to_formbricks": "Впервые на Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Использовать резервный код"
|
||||
},
|
||||
"saml_connection_error": "Что-то пошло не так. Пожалуйста, проверьте консоль приложения для получения подробностей.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Внизу справа",
|
||||
"cancel": "Отмена",
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"change_organization": "Сменить организацию",
|
||||
"change_workspace": "Сменить рабочее пространство",
|
||||
"choices": "Варианты",
|
||||
"choose_environment": "Выберите среду",
|
||||
"choose_organization": "Выберите организацию",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Уведомления",
|
||||
"number": "Номер",
|
||||
"off": "Выкл.",
|
||||
"offline_all_responses_synced": "Твой ответ успешно сохранён.",
|
||||
"offline_syncing_responses": "Синхронизируем твои ответы…",
|
||||
"offline_you_are_offline": "Ты не в сети. Твой ответ сохранён в браузере и будет отправлен, когда подключение восстановится.",
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Название команды",
|
||||
"team_role": "Роль в команде",
|
||||
"teams": "Команды",
|
||||
"terms_of_service": "Условия использования",
|
||||
"text": "Текст",
|
||||
"time": "Время",
|
||||
"time_to_finish": "Время до завершения",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
|
||||
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
|
||||
"reconnect_button": "Переподключить",
|
||||
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
|
||||
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
|
||||
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "ИИ-анализ и обогащение данных отключены для этой организации.",
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
|
||||
"ai_features_not_enabled_for_organization": "Функции ИИ не включены для этой организации.",
|
||||
"ai_instance_not_configured": "ИИ настраивается на уровне инстанса через переменные окружения. Попросите администратора настроить AI_PROVIDER, учетные данные этого провайдера и соответствующий список моделей перед включением функций ИИ.",
|
||||
"ai_settings_updated_successfully": "Настройки AI успешно обновлены",
|
||||
"ai_smart_tools_disabled_for_organization": "Интеллектуальные функции ИИ отключены для этой организации.",
|
||||
"ai_smart_tools_enabled": "Умные функции (ИИ)",
|
||||
"ai_smart_tools_enabled_description": "ИИ помогает тебе делать больше за меньшее время. Никогда не использует данные, собранные с помощью Formbricks. Применяется, например, для перевода опросов на другие языки.",
|
||||
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Только владельцы организации имеют доступ к этой настройке.",
|
||||
"organization_created_successfully": "Организация успешно создана!",
|
||||
"organization_deleted_successfully": "Организация успешно удалена.",
|
||||
"organization_deletion_disabled": "Удаление организаций отключено.",
|
||||
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
|
||||
"organization_name": "Название организации",
|
||||
"organization_name_description": "Дайте вашей организации понятное название.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
|
||||
"update_personal_info": "Обновить личную информацию",
|
||||
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
|
||||
"warning_cannot_undo": "Это действие необратимо"
|
||||
"warning_cannot_undo": "Это действие необратимо",
|
||||
"wrong_password": "Неверный пароль"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Добавьте участников в команду и определите их роль.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Верхняя метка",
|
||||
"url_filters": "Фильтры URL",
|
||||
"url_not_supported": "URL не поддерживается",
|
||||
"validate_id_duplicate": "ID {type} уже существует в вопросах, скрытых полях или переменных.",
|
||||
"validate_id_empty": "Пожалуйста, введите ID {type}.",
|
||||
"validate_id_invalid_chars": "ID {type} недопустим. Пожалуйста, используйте только буквенно-цифровые символы, дефисы или подчеркивания.",
|
||||
"validate_id_no_spaces": "ID {type} не может содержать пробелы. Пожалуйста, удалите пробелы.",
|
||||
"validate_id_reserved": "ID {type} \"{field}\" недопустим. Это зарезервированное ключевое слово.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Добавить правило проверки",
|
||||
"answer_all_rows": "Ответьте на все строки",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "В этом квартале",
|
||||
"this_year": "В этом году",
|
||||
"time_to_complete": "Время на прохождение",
|
||||
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
|
||||
"ttc_tooltip": "Среднее время на ответ на вопрос.",
|
||||
"unknown_question_type": "Неизвестный тип вопроса",
|
||||
"use_personal_links": "Использовать персональные ссылки",
|
||||
@@ -2580,11 +2603,11 @@
|
||||
"churn_survey_question_2_headline": "Что бы сделало использование $[projectName] проще?",
|
||||
"churn_survey_question_3_button_label": "Получить скидку 30%",
|
||||
"churn_survey_question_3_headline": "Получите скидку 30% на следующий год!",
|
||||
"churn_survey_question_3_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы бы очень хотели, чтобы вы остались нашим клиентом. Готовы предложить вам скидку 30% на следующий год.</span></p>",
|
||||
"churn_survey_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы бы очень хотели, чтобы вы остались нашим клиентом. Готовы предложить вам скидку 30% на следующий год.</span></p>",
|
||||
"churn_survey_question_4_headline": "Каких функций вам не хватает?",
|
||||
"churn_survey_question_5_button_label": "Написать письмо CEO",
|
||||
"churn_survey_question_5_headline": "Очень жаль это слышать 😔 Свяжитесь напрямую с нашим CEO!",
|
||||
"churn_survey_question_5_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы стремимся предоставлять лучший сервис для клиентов. Пожалуйста, напишите нашему CEO, и она лично займётся вашей проблемой.</span></p>",
|
||||
"churn_survey_question_5_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы стремимся предоставлять лучший сервис для клиентов. Пожалуйста, напишите нашему CEO, и она лично займётся вашей проблемой.</span></p>",
|
||||
"collect_feedback_description": "Соберите подробную обратную связь о вашем продукте или услуге.",
|
||||
"collect_feedback_name": "Сбор отзывов",
|
||||
"collect_feedback_question_1_headline": "Как вы оцениваете свой общий опыт?",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
|
||||
"csat_question_1_lower_label": "Маловероятно",
|
||||
"csat_question_1_upper_label": "Очень вероятно",
|
||||
"csat_question_2_choice_1": "Очень доволен",
|
||||
"csat_question_2_choice_2": "В целом доволен",
|
||||
"csat_question_2_choice_1": "Скорее доволен",
|
||||
"csat_question_2_choice_2": "Очень доволен",
|
||||
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
|
||||
"csat_question_2_choice_4": "В целом недоволен",
|
||||
"csat_question_2_choice_5": "Очень недоволен",
|
||||
@@ -2776,7 +2799,7 @@
|
||||
"evaluate_a_product_idea_name": "Оценка идеи продукта",
|
||||
"evaluate_a_product_idea_question_1_button_label": "Давайте попробуем!",
|
||||
"evaluate_a_product_idea_question_1_headline": "Нам нравится, как вы используете $[projectName]! Мы хотели бы узнать ваше мнение о новой функции. Есть минутка?",
|
||||
"evaluate_a_product_idea_question_1_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Мы ценим ваше время, поэтому всё коротко 🤸</span></p>",
|
||||
"evaluate_a_product_idea_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы ценим ваше время, поэтому всё коротко 🤸</span></p>",
|
||||
"evaluate_a_product_idea_question_2_headline": "Спасибо! Насколько сложно или легко вам сейчас решать [PROBLEM AREA]?",
|
||||
"evaluate_a_product_idea_question_2_lower_label": "Очень сложно",
|
||||
"evaluate_a_product_idea_question_2_upper_label": "Очень легко",
|
||||
@@ -2784,7 +2807,7 @@
|
||||
"evaluate_a_product_idea_question_3_placeholder": "Введите ваш ответ здесь...",
|
||||
"evaluate_a_product_idea_question_4_button_label": "Далее",
|
||||
"evaluate_a_product_idea_question_4_headline": "Мы работаем над идеей, которая поможет с [PROBLEM AREA].",
|
||||
"evaluate_a_product_idea_question_4_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Вставьте краткое описание концепции. Добавьте необходимые детали, но сделайте текст лаконичным и понятным.</span></p>",
|
||||
"evaluate_a_product_idea_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Вставьте краткое описание концепции. Добавьте необходимые детали, но сделайте текст лаконичным и понятным.</span></p>",
|
||||
"evaluate_a_product_idea_question_5_headline": "Насколько ценна для вас была бы эта функция?",
|
||||
"evaluate_a_product_idea_question_5_lower_label": "Не ценна",
|
||||
"evaluate_a_product_idea_question_5_upper_label": "Очень ценна",
|
||||
@@ -2879,7 +2902,7 @@
|
||||
"identify_sign_up_barriers_question_8_placeholder": "Введите ваш ответ здесь...",
|
||||
"identify_sign_up_barriers_question_9_button_label": "Зарегистрироваться",
|
||||
"identify_sign_up_barriers_question_9_headline": "Спасибо! Вот ваш код: SIGNUPNOW10",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Большое спасибо, что нашли время поделиться своим мнением 🙏</span></p>",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Большое спасибо, что нашли время поделиться своим мнением 🙏</span></p>",
|
||||
"identify_upsell_opportunities_description": "Узнайте, сколько времени ваш продукт экономит пользователю. Используйте это для апсейла.",
|
||||
"identify_upsell_opportunities_name": "Поиск возможностей для апсейла",
|
||||
"identify_upsell_opportunities_question_1_choice_1": "Меньше 1 часа",
|
||||
@@ -3121,7 +3144,7 @@
|
||||
"product_market_fit_superhuman_description": "Оцените PMF, выяснив, насколько пользователи были бы разочарованы, если бы ваш продукт исчез.",
|
||||
"product_market_fit_superhuman_question_1_button_label": "Рады помочь!",
|
||||
"product_market_fit_superhuman_question_1_headline": "Вы один из наших активных пользователей! У вас есть 5 минут?",
|
||||
"product_market_fit_superhuman_question_1_html": "<p class=\\\"fb-editor-paragraph\\\" dir=\\\"ltr\\\"><span>Нам бы хотелось лучше понять ваш пользовательский опыт. Ваше мнение очень важно для нас.</span></p>",
|
||||
"product_market_fit_superhuman_question_1_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Нам бы хотелось лучше понять ваш пользовательский опыт. Ваше мнение очень важно для нас.</span></p>",
|
||||
"product_market_fit_superhuman_question_2_choice_1": "Совсем не расстроюсь",
|
||||
"product_market_fit_superhuman_question_2_choice_2": "Буду немного расстроен",
|
||||
"product_market_fit_superhuman_question_2_choice_3": "Буду очень расстроен",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "Logga in med e-post",
|
||||
"lost_access": "Förlorad åtkomst?",
|
||||
"new_to_formbricks": "Ny på Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "Använd en reservkod"
|
||||
},
|
||||
"saml_connection_error": "Något gick fel. Kontrollera din appkonsol för mer information.",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "Nedre höger",
|
||||
"cancel": "Avbryt",
|
||||
"centered_modal": "Centrerad modal",
|
||||
"change_organization": "Byt organisation",
|
||||
"change_workspace": "Byt arbetsyta",
|
||||
"choices": "Val",
|
||||
"choose_environment": "Välj miljö",
|
||||
"choose_organization": "Välj organisation",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "Aviseringar",
|
||||
"number": "Nummer",
|
||||
"off": "Av",
|
||||
"offline_all_responses_synced": "Ditt svar har sparats.",
|
||||
"offline_syncing_responses": "Synkroniserar dina svar…",
|
||||
"offline_you_are_offline": "Du är offline. Ditt svar är lagrat i din webbläsare och kommer att sparas när du är online igen.",
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_role": "Teamroll",
|
||||
"teams": "Åtkomstkontroll",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"text": "Text",
|
||||
"time": "Tid",
|
||||
"time_to_finish": "Tid att slutföra",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "Skicka data till din Notion-databas",
|
||||
"please_select_a_survey_error": "Vänligen välj en enkät",
|
||||
"reconnect_button": "Återanslut",
|
||||
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
|
||||
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
|
||||
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "AI-baserad dataanalys och databerikning är inaktiverad för den här organisationen.",
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
|
||||
"ai_features_not_enabled_for_organization": "AI-funktioner är inte aktiverade för den här organisationen.",
|
||||
"ai_instance_not_configured": "AI konfigureras på instansnivå via miljövariabler. Be din administratör att ange AI_PROVIDER, autentiseringsuppgifterna för den leverantören och den tillhörande modellistan innan AI-funktioner aktiveras.",
|
||||
"ai_settings_updated_successfully": "AI-inställningarna har uppdaterats",
|
||||
"ai_smart_tools_disabled_for_organization": "AI-smartverktyg är inaktiverade för den här organisationen.",
|
||||
"ai_smart_tools_enabled": "Smarta funktioner (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI som hjälper dig att göra mer på kortare tid. Rör aldrig data som samlats in med Formbricks. Används bara till t.ex. att översätta enkäter till andra språk.",
|
||||
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "Endast organisationsägare kan komma åt denna inställning.",
|
||||
"organization_created_successfully": "Organisation skapad!",
|
||||
"organization_deleted_successfully": "Organisation borttagen.",
|
||||
"organization_deletion_disabled": "Borttagning av organisationer är inaktiverad.",
|
||||
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
|
||||
"organization_name": "Organisationsnamn",
|
||||
"organization_name_description": "Ge din organisation ett beskrivande namn.",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
|
||||
"update_personal_info": "Uppdatera din personliga information",
|
||||
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
|
||||
"warning_cannot_undo": "Detta kan inte ångras"
|
||||
"warning_cannot_undo": "Detta kan inte ångras",
|
||||
"wrong_password": "Fel lösenord"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "Övre etikett",
|
||||
"url_filters": "URL-filter",
|
||||
"url_not_supported": "URL stöds inte",
|
||||
"validate_id_duplicate": "{type}-ID finns redan i frågor, dolda fält eller variabler.",
|
||||
"validate_id_empty": "Vänligen ange ett {type}-ID.",
|
||||
"validate_id_invalid_chars": "{type}-ID är inte tillåtet. Använd endast alfanumeriska tecken, bindestreck eller understreck.",
|
||||
"validate_id_no_spaces": "{type}-ID kan inte innehålla mellanslag. Vänligen ta bort mellanslag.",
|
||||
"validate_id_reserved": "{type}-ID \"{field}\" är inte tillåtet. Det är ett reserverat nyckelord.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Lägg till valideringsregel",
|
||||
"answer_all_rows": "Svara på alla rader",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "Detta kvartal",
|
||||
"this_year": "Detta år",
|
||||
"time_to_complete": "Tid att slutföra",
|
||||
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
|
||||
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
|
||||
"unknown_question_type": "Okänd frågetyp",
|
||||
"use_personal_links": "Använd personliga länkar",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "Hur troligt är det att du skulle rekommendera $[projectName] till en vän eller kollega?",
|
||||
"csat_question_1_lower_label": "Inte troligt",
|
||||
"csat_question_1_upper_label": "Mycket troligt",
|
||||
"csat_question_2_choice_1": "Mycket nöjd",
|
||||
"csat_question_2_choice_2": "Ganska nöjd",
|
||||
"csat_question_2_choice_1": "Ganska nöjd",
|
||||
"csat_question_2_choice_2": "Mycket nöjd",
|
||||
"csat_question_2_choice_3": "Varken nöjd eller missnöjd",
|
||||
"csat_question_2_choice_4": "Ganska missnöjd",
|
||||
"csat_question_2_choice_5": "Mycket missnöjd",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "使用 邮箱 登录",
|
||||
"lost_access": "失去访问?",
|
||||
"new_to_formbricks": "新用户 Formbricks ?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "使用 备用代码"
|
||||
},
|
||||
"saml_connection_error": "出了问题。请查看应用 控制台 以获取更多详情。",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "居中 模态",
|
||||
"change_organization": "切换组织",
|
||||
"change_workspace": "切换工作区",
|
||||
"choices": "选项",
|
||||
"choose_environment": "选择 环境",
|
||||
"choose_organization": "选择 组织",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "通知",
|
||||
"number": "数字",
|
||||
"off": "关闭",
|
||||
"offline_all_responses_synced": "您的回复已成功保存。",
|
||||
"offline_syncing_responses": "正在同步您的回复…",
|
||||
"offline_you_are_offline": "您当前处于离线状态。您的回复已存储在浏览器中,将在您重新联网后保存。",
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"terms_of_service": "服务条款",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
"time_to_finish": "完成 时间",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "更改 密码",
|
||||
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
|
||||
"forgot_password_email_heading": "更改 密码",
|
||||
"forgot_password_email_link_valid_for_24_hours": "链接在{minutes}分钟内有效。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "该链接有效期为 {minutes} 分钟。",
|
||||
"forgot_password_email_subject": "重置您的 Formbricks 密码",
|
||||
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
|
||||
"hidden_field": "隐藏字段",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
|
||||
"please_select_a_survey_error": "请选择 一个 调查",
|
||||
"reconnect_button": "重新连接",
|
||||
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
|
||||
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
|
||||
"select_at_least_one_question_error": "请选择至少 一个问题",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "此组织的 AI 数据分析和增强功能已禁用。",
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
|
||||
"ai_features_not_enabled_for_organization": "此组织未启用 AI 功能。",
|
||||
"ai_instance_not_configured": "AI 通过环境变量在实例级别进行配置。启用 AI 功能前,请让管理员设置 AI_PROVIDER、该提供商的凭据以及对应的模型列表。",
|
||||
"ai_settings_updated_successfully": "AI 设置已成功更新",
|
||||
"ai_smart_tools_disabled_for_organization": "此组织的 AI 智能功能已禁用。",
|
||||
"ai_smart_tools_enabled": "智能功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 帮你更高效地完成更多任务。绝不会接触 Formbricks 收集的数据,仅用于如问卷翻译等功能。",
|
||||
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "只有 组织 所有者 可以 访问 此 设置。",
|
||||
"organization_created_successfully": "组织 创建 成功",
|
||||
"organization_deleted_successfully": "组织 删除 成功",
|
||||
"organization_deletion_disabled": "组织删除已禁用。",
|
||||
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
|
||||
"organization_name": "组织 名称",
|
||||
"organization_name_description": "为您的组织 提供一个描述性的名称",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
|
||||
"update_personal_info": "更新你的个人信息",
|
||||
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
|
||||
"warning_cannot_undo": "此 无法 撤销。"
|
||||
"warning_cannot_undo": "此 无法 撤销。",
|
||||
"wrong_password": "密码错误"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "上限标签",
|
||||
"url_filters": "URL 过滤器",
|
||||
"url_not_supported": "URL 不支持",
|
||||
"validate_id_duplicate": "{type} ID 已存在于问题、隐藏字段或变量中。",
|
||||
"validate_id_empty": "请输入 {type} ID。",
|
||||
"validate_id_invalid_chars": "{type} ID 不允许使用。请仅使用字母数字字符、连字符或下划线。",
|
||||
"validate_id_no_spaces": "{type} ID 不能包含空格。请删除空格。",
|
||||
"validate_id_reserved": "{type} ID \"{field}\" 不允许使用。它是保留关键字。",
|
||||
"validation": {
|
||||
"add_validation_rule": "添加验证规则",
|
||||
"answer_all_rows": "请填写所有行",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "本季度",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成时间",
|
||||
"ttc_survey_tooltip": "完成调查的平均时间。",
|
||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||
"unknown_question_type": "未知 问题 类型",
|
||||
"use_personal_links": "使用 个人 链接",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ?",
|
||||
"csat_question_1_lower_label": "不可能",
|
||||
"csat_question_1_upper_label": "非常 可能",
|
||||
"csat_question_2_choice_1": "非常 满意",
|
||||
"csat_question_2_choice_2": "有点 满意",
|
||||
"csat_question_2_choice_1": "比较满意",
|
||||
"csat_question_2_choice_2": "非常满意",
|
||||
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
|
||||
"csat_question_2_choice_4": "有点 不满意",
|
||||
"csat_question_2_choice_5": "非常 不 满意",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"login_with_email": "使用電子郵件登入",
|
||||
"lost_access": "無法存取?",
|
||||
"new_to_formbricks": "初次使用 Formbricks?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"use_a_backup_code": "使用備份碼"
|
||||
},
|
||||
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
|
||||
@@ -148,6 +150,8 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "置中彈窗",
|
||||
"change_organization": "變更組織",
|
||||
"change_workspace": "變更工作區",
|
||||
"choices": "選項",
|
||||
"choose_environment": "選擇環境",
|
||||
"choose_organization": "選擇 組織",
|
||||
@@ -317,6 +321,9 @@
|
||||
"notifications": "通知",
|
||||
"number": "數字",
|
||||
"off": "關閉",
|
||||
"offline_all_responses_synced": "你的回應已成功儲存。",
|
||||
"offline_syncing_responses": "正在同步你的回應…",
|
||||
"offline_you_are_offline": "你目前離線。你的回應已暫存在瀏覽器中,並將在恢復網路連線後自動儲存。",
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
@@ -433,6 +440,7 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"terms_of_service": "服務條款",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
"time_to_finish": "完成時間",
|
||||
@@ -505,7 +513,7 @@
|
||||
"forgot_password_email_change_password": "變更密碼",
|
||||
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
|
||||
"forgot_password_email_heading": "變更密碼",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期限為 {minutes} 分鐘。",
|
||||
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
|
||||
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
|
||||
"hidden_field": "隱藏欄位",
|
||||
@@ -820,6 +828,9 @@
|
||||
},
|
||||
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
|
||||
"please_select_a_survey_error": "請選取問卷",
|
||||
"reconnect_button": "重新連接",
|
||||
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
|
||||
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
|
||||
"select_at_least_one_question_error": "請選取至少一個問題",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
|
||||
@@ -1129,11 +1140,15 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "此組織的 AI 資料分析與增強功能已停用。",
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理此組織的 AI 功能。",
|
||||
"ai_features_not_enabled_for_organization": "此組織尚未啟用 AI 功能。",
|
||||
"ai_instance_not_configured": "AI 會透過環境變數在實例層級進行設定。啟用 AI 功能前,請管理員設定 AI_PROVIDER、該供應商的憑證,以及對應的模型清單。",
|
||||
"ai_settings_updated_successfully": "AI 設定已成功更新",
|
||||
"ai_smart_tools_disabled_for_organization": "此組織的 AI 智慧功能已停用。",
|
||||
"ai_smart_tools_enabled": "智慧功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 幫你更快完成更多事。絕不會接觸 Formbricks 收集的資料,只用於像是將問卷翻譯成其他語言等用途。",
|
||||
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
|
||||
@@ -1174,6 +1189,7 @@
|
||||
"only_org_owner_can_perform_action": "只有組織擁有者才能存取此設定。",
|
||||
"organization_created_successfully": "組織已成功建立!",
|
||||
"organization_deleted_successfully": "組織已成功刪除。",
|
||||
"organization_deletion_disabled": "組織刪除已停用。",
|
||||
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
|
||||
"organization_name": "組織名稱",
|
||||
"organization_name_description": "為您的組織提供描述性名稱。",
|
||||
@@ -1235,7 +1251,8 @@
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"wrong_password": "密碼錯誤"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1769,6 +1786,11 @@
|
||||
"upper_label": "上標籤",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"validate_id_duplicate": "{type} ID 已存在於問題、隱藏欄位或變數中。",
|
||||
"validate_id_empty": "請輸入 {type} ID。",
|
||||
"validate_id_invalid_chars": "不允許使用此 {type} ID。請僅使用英數字元、連字號或底線。",
|
||||
"validate_id_no_spaces": "{type} ID 不能包含空格。請移除空格。",
|
||||
"validate_id_reserved": "不允許使用 {type} ID「{field}」。這是保留關鍵字。",
|
||||
"validation": {
|
||||
"add_validation_rule": "新增驗證規則",
|
||||
"answer_all_rows": "請填答所有列",
|
||||
@@ -2109,6 +2131,7 @@
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
|
||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
@@ -2619,8 +2642,8 @@
|
||||
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
|
||||
"csat_question_1_lower_label": "不太可能",
|
||||
"csat_question_1_upper_label": "非常可能",
|
||||
"csat_question_2_choice_1": "非常滿意",
|
||||
"csat_question_2_choice_2": "有點滿意",
|
||||
"csat_question_2_choice_1": "有點滿意",
|
||||
"csat_question_2_choice_2": "非常滿意",
|
||||
"csat_question_2_choice_3": "既不滿意也不不滿意",
|
||||
"csat_question_2_choice_4": "有點不滿意",
|
||||
"csat_question_2_choice_5": "非常不滿意",
|
||||
|
||||
@@ -1,24 +1,89 @@
|
||||
"use server";
|
||||
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.action(
|
||||
withAuditLogging("deleted", "user", async ({ ctx }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
|
||||
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
|
||||
"Password and email confirmation are required to delete your account.";
|
||||
|
||||
const ZDeleteUserConfirmation = z
|
||||
.object({
|
||||
confirmationEmail: z.string().trim().min(1).max(255),
|
||||
password: z.string().max(128).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parseDeleteUserConfirmation = (input: unknown) => {
|
||||
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return parsedInput.data;
|
||||
};
|
||||
|
||||
const getPasswordOrThrow = (password?: string) => {
|
||||
if (!password) {
|
||||
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return password;
|
||||
};
|
||||
|
||||
const logAccountDeletionError = (userId: string, error: unknown) => {
|
||||
logger.error({ error, userId }, "Account deletion failed");
|
||||
};
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
|
||||
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
|
||||
const result = await deleteUser(ctx.user.id);
|
||||
return result;
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
|
||||
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
|
||||
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
|
||||
|
||||
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
|
||||
throw new AuthorizationError("Email confirmation does not match");
|
||||
}
|
||||
|
||||
if (isPasswordBackedAccount) {
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) {
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
|
||||
if (organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
|
||||
|
||||
await deleteUser(ctx.user.id);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
|
||||
@@ -3,12 +3,16 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { deleteUserAction } from "./actions";
|
||||
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
open: boolean;
|
||||
@@ -28,15 +32,57 @@ export const DeleteAccountModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
const isPasswordBackedAccount = user.identityProvider === "email";
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setInputValue("");
|
||||
setPassword("");
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
|
||||
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
|
||||
const isDeleteDisabled = !hasValidConfirmation;
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
const result = await deleteUserAction(
|
||||
isPasswordBackedAccount
|
||||
? {
|
||||
confirmationEmail: inputValue,
|
||||
password,
|
||||
}
|
||||
: {
|
||||
confirmationEmail: inputValue,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result?.data?.success) {
|
||||
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
|
||||
let errorMessage = fallbackErrorMessage;
|
||||
|
||||
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
|
||||
errorMessage = t("environments.settings.profile.wrong_password");
|
||||
} else if (result) {
|
||||
errorMessage = getFormattedErrorMessage(result);
|
||||
}
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
@@ -52,22 +98,22 @@ export const DeleteAccountModal = ({
|
||||
window.location.replace("/auth/login");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong");
|
||||
logger.error({ error }, "Account deletion failed");
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
setOpen={handleOpenChange}
|
||||
deleteWhat={t("common.account")}
|
||||
onDelete={() => deleteAccount()}
|
||||
text={t("environments.settings.profile.account_deletion_consequences_warning")}
|
||||
isDeleting={deleting}
|
||||
disabled={inputValue !== user.email}>
|
||||
disabled={isDeleteDisabled}>
|
||||
<div className="py-5">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>
|
||||
@@ -110,11 +156,29 @@ export const DeleteAccountModal = ({
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={user.email}
|
||||
className="mt-5"
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteAccountConfirmation"
|
||||
name="deleteAccountConfirmation"
|
||||
/>
|
||||
{isPasswordBackedAccount && (
|
||||
<>
|
||||
<label htmlFor="deleteAccountPassword" className="mt-4 block">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
data-testid="deleteAccountPassword"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
containerClassName="mt-2"
|
||||
id="deleteAccountPassword"
|
||||
name="deleteAccountPassword"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
|
||||
+6
-4
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
/>
|
||||
);
|
||||
} else if (Array.isArray(responseData)) {
|
||||
const itemsArray = responseData.map((choice) => {
|
||||
const choiceId = getChoiceIdByValue(choice, element);
|
||||
return { value: choice, id: choiceId };
|
||||
});
|
||||
const itemsArray = responseData
|
||||
.filter((choice) => choice !== "")
|
||||
.map((choice) => {
|
||||
const choiceId = getChoiceIdByValue(choice, element);
|
||||
return { value: choice, id: choiceId };
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TResponse, TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { DecrementQuotasCheckbox } from "@/modules/ui/components/decrement-quotas-checkbox";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -104,7 +105,11 @@ export const SingleResponseCard = ({
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.not_authorized"));
|
||||
}
|
||||
await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
const result = await deleteResponseAction({ responseId: response.id, decrementQuotas });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
updateResponseList?.([response.id]);
|
||||
if (setSelectedResponseId) setSelectedResponseId(null);
|
||||
toast.success(t("environments.surveys.responses.response_deleted_successfully"));
|
||||
|
||||
@@ -12,6 +12,10 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
logApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticatedApiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
|
||||
import { createUser, getUsers, updateUser } from "../users";
|
||||
|
||||
@@ -11,6 +13,10 @@ const mockUser = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isActive: true,
|
||||
password: "$2b$12$hashedPassword",
|
||||
twoFactorSecret: "encrypted-2fa-secret",
|
||||
backupCodes: "encrypted-backup-codes",
|
||||
identityProviderAccountId: "provider-account-id",
|
||||
role: "admin",
|
||||
memberships: [{ organizationId: "org456", role: "admin" }],
|
||||
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
|
||||
@@ -58,6 +64,10 @@ describe("Users Lib", () => {
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(result.data.data[0]).not.toHaveProperty("password");
|
||||
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
|
||||
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,6 +92,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.id).toBe(mockUser.id);
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,6 +107,51 @@ describe("Users Lib", () => {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns conflict error if user with email already exists", async () => {
|
||||
(prisma.user.create as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed on the fields: (`email`)",
|
||||
{
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["email"] },
|
||||
}
|
||||
)
|
||||
);
|
||||
const result = await createUser(
|
||||
{ name: "Duplicate", email: "test@example.com", role: "member" },
|
||||
"org456"
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("conflict");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "email", issue: "A user with this email already exists" },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error if unique constraint violation is not on email", async () => {
|
||||
(prisma.user.create as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed on the fields: (`organizationId`,`email`)",
|
||||
{
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["organizationId"] },
|
||||
}
|
||||
)
|
||||
);
|
||||
const result = await createUser(
|
||||
{ name: "Duplicate", email: "test@example.com", role: "member" },
|
||||
"org456"
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
@@ -104,6 +163,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.name).toBe("Updated User");
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TUser } from "@formbricks/database/zod/users";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
|
||||
@@ -142,6 +143,20 @@ export const createUser = async (
|
||||
|
||||
return ok(returnedUser);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const target = error.meta?.target as string[] | undefined;
|
||||
|
||||
if (target?.includes("email")) {
|
||||
return err({
|
||||
type: "conflict",
|
||||
details: [{ field: "email", issue: "A user with this email already exists" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "user", issue: error instanceof Error ? error.message : "Unknown error occurred" }],
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { capturePostHogEvent } from "@/lib/posthog";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
@@ -35,8 +36,19 @@ vi.mock("@/lib/encryption", () => ({
|
||||
symmetricDecrypt: vi.fn((value: string) => value.replace("encrypted_", "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/crypto")>();
|
||||
return {
|
||||
...actual,
|
||||
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
|
||||
symmetricDecrypt: vi.fn((value: string) => value.replace("encrypted_", "")),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock JWT
|
||||
vi.mock("@/lib/jwt");
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyToken: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
@@ -70,6 +82,7 @@ vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -105,9 +118,21 @@ vi.mock("@formbricks/database", () => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
membership: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/user", () => ({
|
||||
updateUser: vi.fn(),
|
||||
updateUserLastLoginAt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to get the provider by id from authOptions.providers.
|
||||
function getProviderById(id: string): Provider {
|
||||
const provider = authOptions.providers.find((p) => p.options.id === id);
|
||||
@@ -133,7 +158,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
@@ -141,10 +166,10 @@ describe("authOptions", () => {
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Invalid credentials"
|
||||
);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("should throw error if user has no password stored", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
@@ -156,10 +181,10 @@ describe("authOptions", () => {
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"User has no password stored"
|
||||
);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("should throw error if password verification fails", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -171,10 +196,10 @@ describe("authOptions", () => {
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Invalid credentials"
|
||||
);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
test("should successfully login when credentials are valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
const fakeUser = {
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -193,11 +218,11 @@ describe("authOptions", () => {
|
||||
email: fakeUser.email,
|
||||
emailVerified: fakeUser.emailVerified,
|
||||
});
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before credential validation", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -230,7 +255,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -253,7 +278,7 @@ describe("authOptions", () => {
|
||||
|
||||
describe("Two-Factor Backup Code login", () => {
|
||||
test("should throw error if backup codes are missing", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -282,7 +307,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or user not found", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
const credentials = { token: "badtoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
@@ -292,7 +317,7 @@ describe("authOptions", () => {
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
@@ -347,6 +372,122 @@ describe("authOptions", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("should capture user_signed_in PostHog event on successful credentials sign-in", async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
const account = { provider: "credentials" } as any;
|
||||
|
||||
vi.mocked(prisma.membership.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ lastLoginAt: yesterday } as any);
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
const result = await authOptions.callbacks.signIn({ user, account });
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Allow the fire-and-forget captureSignIn to resolve
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(user.id, "user_signed_in", {
|
||||
auth_provider: "credentials",
|
||||
organization_count: 2,
|
||||
is_first_login_today: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should set is_first_login_today to false when user already logged in today", async () => {
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
const account = { provider: "credentials" } as any;
|
||||
|
||||
vi.mocked(prisma.membership.count).mockResolvedValue(1);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ lastLoginAt: new Date() } as any);
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
await authOptions.callbacks.signIn({ user, account });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
"user_signed_in",
|
||||
expect.objectContaining({ is_first_login_today: false })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("should not throw if captureSignIn prisma query fails", async () => {
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
const account = { provider: "credentials" } as any;
|
||||
|
||||
vi.mocked(prisma.membership.count).mockRejectedValue(new Error("DB error"));
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
const result = await authOptions.callbacks.signIn({ user, account });
|
||||
expect(result).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enterprise SSO signIn callback", () => {
|
||||
test("should not update lastLoginAt when SSO sign-in is denied", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const mockHandleSsoCallback = vi.fn().mockRejectedValueOnce(new Error("OAuthAccountNotLinked"));
|
||||
const mockUpdateUserLastLoginAt = vi.fn();
|
||||
const mockCapturePostHogEvent = vi.fn();
|
||||
|
||||
vi.doMock("@/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",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
};
|
||||
});
|
||||
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
|
||||
getSSOProviders: vi.fn(() => []),
|
||||
}));
|
||||
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
|
||||
handleSsoCallback: mockHandleSsoCallback,
|
||||
}));
|
||||
vi.doMock("@/modules/auth/lib/user", () => ({
|
||||
updateUser: vi.fn(),
|
||||
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
|
||||
}));
|
||||
vi.doMock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: mockCapturePostHogEvent,
|
||||
}));
|
||||
|
||||
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
|
||||
|
||||
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).rejects.toThrow(
|
||||
"OAuthAccountNotLinked"
|
||||
);
|
||||
|
||||
expect(mockHandleSsoCallback).toHaveBeenCalled();
|
||||
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
|
||||
expect(mockCapturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -354,7 +495,7 @@ describe("authOptions", () => {
|
||||
const credentialsProvider = getProviderById("credentials");
|
||||
|
||||
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -372,7 +513,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if two factor secret is missing", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
POSTHOG_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import {
|
||||
logAuthAttempt,
|
||||
@@ -330,11 +335,32 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
// get callback url from the cookie store,
|
||||
const callbackUrl =
|
||||
cookieStore.get("__Secure-next-auth.callback-url")?.value ||
|
||||
cookieStore.get("next-auth.callback-url")?.value ||
|
||||
"";
|
||||
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
|
||||
|
||||
const userEmail = user.email ?? "";
|
||||
const userId = user.id as string;
|
||||
|
||||
// Capture sign-in event for PostHog (query BEFORE updating lastLoginAt)
|
||||
const captureSignIn = async (provider: string) => {
|
||||
if (!POSTHOG_KEY) return;
|
||||
|
||||
try {
|
||||
const [membershipCount, userData] = await Promise.all([
|
||||
prisma.membership.count({ where: { userId } }),
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { lastLoginAt: true } }),
|
||||
]);
|
||||
const isFirstLoginToday =
|
||||
userData?.lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
|
||||
|
||||
capturePostHogEvent(userId, "user_signed_in", {
|
||||
auth_provider: provider,
|
||||
organization_count: membershipCount,
|
||||
is_first_login_today: isFirstLoginToday,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error }, "Failed to capture PostHog sign-in event");
|
||||
}
|
||||
};
|
||||
|
||||
if (account?.provider === "credentials" || account?.provider === "token") {
|
||||
// check if user's email is verified or not
|
||||
@@ -342,6 +368,7 @@ export const authOptions: NextAuthOptions = {
|
||||
logger.error("Email Verification is Pending");
|
||||
throw new Error("Email Verification is Pending");
|
||||
}
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
return true;
|
||||
}
|
||||
@@ -353,10 +380,12 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
|
||||
if (result) {
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void captureSignIn(account?.provider ?? "unknown");
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getAuthCallbackUrlFromCookies,
|
||||
getInviteTokenFromCallbackUrl,
|
||||
getRelativeCallbackUrl,
|
||||
resolveAuthCallbackUrl,
|
||||
} from "./callback-url";
|
||||
|
||||
const WEBAPP_URL = "http://localhost:3000";
|
||||
|
||||
describe("auth callback URL helpers", () => {
|
||||
test("prefers a valid callback URL from search params over the cookie", () => {
|
||||
const result = resolveAuthCallbackUrl({
|
||||
searchParamCallbackUrl: "http://localhost:3000/invite?token=search-token",
|
||||
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token",
|
||||
webAppUrl: WEBAPP_URL,
|
||||
});
|
||||
|
||||
expect(result).toBe("http://localhost:3000/invite?token=search-token");
|
||||
});
|
||||
|
||||
test("falls back to the callback URL cookie when the query string only contains an auth error", () => {
|
||||
const result = resolveAuthCallbackUrl({
|
||||
searchParamCallbackUrl: undefined,
|
||||
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token&source=signin",
|
||||
allowCookieFallback: true,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
});
|
||||
|
||||
expect(result).toBe("http://localhost:3000/invite?token=cookie-token&source=signin");
|
||||
});
|
||||
|
||||
test("does not fall back to the callback URL cookie unless explicitly allowed", () => {
|
||||
const result = resolveAuthCallbackUrl({
|
||||
searchParamCallbackUrl: undefined,
|
||||
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token&source=signin",
|
||||
webAppUrl: WEBAPP_URL,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects callback URLs on a different origin", () => {
|
||||
const result = resolveAuthCallbackUrl({
|
||||
searchParamCallbackUrl: "https://evil.example/invite?token=bad",
|
||||
cookieCallbackUrl: "https://evil.example/fallback",
|
||||
webAppUrl: WEBAPP_URL,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns a relative callback path for in-app navigation", () => {
|
||||
const result = getRelativeCallbackUrl(
|
||||
"http://localhost:3000/invite?token=abc123&source=signin",
|
||||
WEBAPP_URL
|
||||
);
|
||||
|
||||
expect(result).toBe("/invite?token=abc123&source=signin");
|
||||
});
|
||||
|
||||
test("extracts invite tokens from validated callback URLs", () => {
|
||||
const result = getInviteTokenFromCallbackUrl(
|
||||
"http://localhost:3000/invite?token=abc123&source=signin",
|
||||
WEBAPP_URL
|
||||
);
|
||||
|
||||
expect(result).toBe("abc123");
|
||||
});
|
||||
|
||||
test("reads the Auth.js callback URL from the secure cookie first", () => {
|
||||
const result = getAuthCallbackUrlFromCookies({
|
||||
get: (name: string) => {
|
||||
if (name === "__Secure-next-auth.callback-url") {
|
||||
return { value: "http://localhost:3000/invite?token=secure" };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe("http://localhost:3000/invite?token=secure");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user