mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 03:16:58 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77a21d1eab | |||
| 613c91a719 | |||
| ca372b3c8b | |||
| 80e1cc2411 | |||
| fef959e9aa | |||
| 240ce70feb | |||
| c16a77fd66 | |||
| f33cfcd11f | |||
| a164fb213f | |||
| d3cf3f05f2 | |||
| 261d2050fc | |||
| 5b26354f48 | |||
| bd05387d99 | |||
| 9b4be60dd9 | |||
| bad3b7a771 | |||
| 007d99f6b8 | |||
| 03b7dfefe4 | |||
| 9178558ba1 | |||
| a65e6d9093 | |||
| 592d36542f | |||
| 5ec8218666 | |||
| e1a44817f2 | |||
| 7f5b2bf69d | |||
| 60e7c7e8ee | |||
| 7988d7775c | |||
| b7ede6c578 | |||
| 8204a5c652 | |||
| e823e10f9a | |||
| f5c3212b2c | |||
| 2d66fc6987 | |||
| 652970003d | |||
| a8b5e286b6 |
@@ -106,6 +106,13 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion reauthentication #
|
||||
###########################################
|
||||
|
||||
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
@@ -132,6 +139,9 @@ GITHUB_SECRET=
|
||||
# Configure Google Login
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
|
||||
# Keep this unset until that setting is active for the OAuth app.
|
||||
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
|
||||
# Configure Azure Active Directory Login
|
||||
AZUREAD_CLIENT_ID=
|
||||
|
||||
+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";
|
||||
|
||||
+47
-8
@@ -1,30 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface DeleteAccountProps {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
organizationsWithSingleOwner,
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
}) => {
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
const { t } = useTranslation();
|
||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
||||
? accountDeletionError[0]
|
||||
: accountDeletionError;
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
globalThis.history.replaceState(null, "", url.toString());
|
||||
}, [accountDeletionErrorCode, t]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +70,7 @@ export const DeleteAccount = ({
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
|
||||
+1
-87
@@ -1,12 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
import { getIsEmailUnique } from "./user";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -17,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
|
||||
-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({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -15,10 +16,14 @@ import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
||||
}) => {
|
||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const { environmentId } = params;
|
||||
|
||||
@@ -33,6 +38,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -90,6 +96,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="" />
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="summary" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.responses")} />
|
||||
<div className="flex h-9 animate-pulse gap-1.5">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="responseTable" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
-55
@@ -191,61 +191,6 @@ describe("getSurveySummaryMeta", () => {
|
||||
expect(meta.dropOffPercentage).toBe(0);
|
||||
expect(meta.ttcAverage).toBe(0);
|
||||
});
|
||||
|
||||
test("uses block-level TTC to avoid multiplying by number of elements", () => {
|
||||
const surveyWithOneBlockThreeElements: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b", q3: "c" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
|
||||
finished: true,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
|
||||
expect(meta.ttcAverage).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveySummaryDropOff", () => {
|
||||
|
||||
+1
-7
@@ -1094,9 +1094,7 @@ export const getResponsesForSummary = reactCache(
|
||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
id: responsePrisma.id,
|
||||
data: (responsePrisma.data ?? {}) as TResponseData,
|
||||
updatedAt: responsePrisma.updatedAt,
|
||||
...responsePrisma,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
@@ -1105,10 +1103,6 @@ export const getResponsesForSummary = reactCache(
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
|
||||
language: responsePrisma.language,
|
||||
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
|
||||
finished: responsePrisma.finished,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
+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>
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
};
|
||||
|
||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
if (Array.isArray(intent)) {
|
||||
return intent[0];
|
||||
}
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return "/auth/login";
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let redirectPath = "/auth/login";
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: session.user.id,
|
||||
userType: "user",
|
||||
targetId: session.user.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status: "success",
|
||||
});
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoReauthCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { captureSurveyResponsePostHogEvent } from "./posthog";
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("captureSurveyResponsePostHogEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const makeParams = (responseCount: number) => ({
|
||||
organizationId: "org-1",
|
||||
surveyId: "survey-1",
|
||||
surveyType: "link",
|
||||
environmentId: "env-1",
|
||||
responseCount,
|
||||
});
|
||||
|
||||
test("fires on 1st response with milestone 'first'", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(1));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("fires on every 100th response", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [100, 200, 300, 500, 1000, 5000]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("does NOT fire for 2nd through 99th responses", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [2, 5, 10, 50, 99]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does NOT fire for non-100th counts above 100", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [101, 150, 250, 499, 501]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sets milestone to count string for non-first milestones", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(200));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"survey_response_received",
|
||||
expect.objectContaining({
|
||||
is_first_response: false,
|
||||
milestone: "200",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, POSTHOG_KEY } 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";
|
||||
@@ -91,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)),
|
||||
]);
|
||||
};
|
||||
|
||||
+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."),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -70,7 +70,6 @@ const mockEnvironmentData = {
|
||||
displayOption: "displayOnce",
|
||||
hiddenFields: { enabled: false },
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
triggers: [],
|
||||
displayPercentage: null,
|
||||
delay: 0,
|
||||
@@ -123,13 +122,6 @@ describe("getEnvironmentStateData", () => {
|
||||
surveys: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
|
||||
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
|
||||
expect(prismaCall.select.surveys.select).toEqual(
|
||||
expect.objectContaining({
|
||||
isAutoProgressingEnabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when environment is not found", async () => {
|
||||
|
||||
@@ -121,7 +121,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
displayOption: true,
|
||||
hiddenFields: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
|
||||
@@ -78,12 +78,16 @@ 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4926,7 +4926,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
|
||||
+3
-2
@@ -784,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
|
||||
@@ -1288,8 +1291,6 @@ checksums:
|
||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
||||
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
|
||||
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
|
||||
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
@@ -33,6 +34,7 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
|
||||
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
|
||||
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
|
||||
|
||||
@@ -123,6 +123,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
@@ -136,6 +137,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
@@ -267,6 +269,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
@@ -280,6 +283,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+86
-1
@@ -1,4 +1,4 @@
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
@@ -266,6 +266,91 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthIntentPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: "account_deletion_sso_reauth";
|
||||
returnToUrl: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "10m",
|
||||
};
|
||||
|
||||
export const createAccountDeletionSsoReauthIntent = (
|
||||
payload: TAccountDeletionSsoReauthIntentPayload,
|
||||
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
|
||||
): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
|
||||
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
|
||||
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: payload.purpose,
|
||||
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
},
|
||||
NEXTAUTH_SECRET,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyAccountDeletionSsoReauthIntent = (
|
||||
token: string
|
||||
): TAccountDeletionSsoReauthIntentPayload => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: string;
|
||||
returnToUrl: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload?.id ||
|
||||
!payload?.userId ||
|
||||
!payload?.email ||
|
||||
!payload?.provider ||
|
||||
!payload?.providerAccountId ||
|
||||
payload?.purpose !== "account_deletion_sso_reauth" ||
|
||||
!payload?.returnToUrl
|
||||
) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
|
||||
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
|
||||
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: "account_deletion_sso_reauth",
|
||||
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
|
||||
@@ -48,7 +48,6 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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";
|
||||
|
||||
export const getUserAuthenticationData = reactCache(
|
||||
async (
|
||||
userId: string
|
||||
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: 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.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) {
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
|
||||
"delete_account": "Konto löschen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
|
||||
"sso_reauthentication_failed": "Die SSO-Authentifizierung ist fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
|
||||
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"two_factor_authentication_description": "Füge eine zusätzliche Sicherheitsebene zu deinem Konto hinzu, falls dein Passwort gestohlen wird.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authentifizierungs-App ein.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
|
||||
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
|
||||
"delete_account": "Delete Account",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Lost access",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
|
||||
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
|
||||
"sso_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted automatically.",
|
||||
"two_factor_authentication": "Two factor authentication",
|
||||
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Assign =",
|
||||
"audience": "Audience",
|
||||
"auto_close_on_inactivity": "Auto close on inactivity",
|
||||
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
|
||||
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Eliminar mi cuenta",
|
||||
"confirm_your_current_password_to_get_started": "Confirma tu contraseña actual para comenzar.",
|
||||
"delete_account": "Eliminar cuenta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desactivar autenticación de dos factores",
|
||||
"disable_two_factor_authentication_description": "Si necesitas desactivar la autenticación de dos factores, te recomendamos volver a activarla lo antes posible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de respaldo puede utilizarse exactamente una vez para conceder acceso sin tu autenticador.",
|
||||
"email_change_initiated": "Tu solicitud de cambio de correo electrónico ha sido iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activar autenticación de dos factores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acceso perdido",
|
||||
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
|
||||
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
|
||||
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
|
||||
"sso_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará automáticamente.",
|
||||
"two_factor_authentication": "Autenticación de dos factores",
|
||||
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
"delete_account": "Supprimer le compte",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Accès perdu",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
|
||||
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
|
||||
"sso_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé automatiquement.",
|
||||
"two_factor_authentication": "Authentification à deux facteurs",
|
||||
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
|
||||
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Saját fiók törlése",
|
||||
"confirm_your_current_password_to_get_started": "Erősítse meg a jelenlegi jelszavát a kezdéshez.",
|
||||
"delete_account": "Fiók törlése",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Kétfaktoros hitelesítés letiltása",
|
||||
"disable_two_factor_authentication_description": "Ha le kell tiltania a kétfaktoros hitelesítést, akkor azt javasoljuk, hogy engedélyezze újra, amint lehetséges.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Minden visszaszerzési kód pontosan egyszer használható a hitelesítő nélküli hozzáférés megszerzéséhez.",
|
||||
"email_change_initiated": "Az e-mail-címe megváltoztatása iránti kérelme kezdeményezve lett.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Elvesztett hozzáférés",
|
||||
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
|
||||
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
|
||||
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
|
||||
"sso_reauthentication_failed": "Az SSO újrahitelesítés nem sikerült. Próbáld meg újra törölni a fiókodat.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthat a személyazonosság-szolgáltatódhoz. Ha a személyazonosságod megerősítést nyer, a fiókod automatikusan törlődik.",
|
||||
"two_factor_authentication": "Kétfaktoros hitelesítés",
|
||||
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "= hozzárendelése",
|
||||
"audience": "Közönség",
|
||||
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
|
||||
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
|
||||
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
|
||||
@@ -828,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": "このチャンネルには別のフォームがすでに接続されています。",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "アカウントを削除",
|
||||
"confirm_your_current_password_to_get_started": "始めるには、現在のパスワードを確認してください。",
|
||||
"delete_account": "アカウントを削除",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "二段階認証を無効にする",
|
||||
"disable_two_factor_authentication_description": "2FAを無効にする必要がある場合は、できるだけ早く再有効にすることをお勧めします。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "各バックアップコードは、認証アプリなしでアクセスを許可するために一度だけ使用できます。",
|
||||
"email_change_initiated": "メールアドレスの変更リクエストが開始されました。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "二段階認証を有効にする",
|
||||
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "アクセスを紛失しましたか",
|
||||
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
|
||||
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
|
||||
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
|
||||
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試してください。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択するとIDプロバイダーにリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
|
||||
"two_factor_authentication": "二段階認証",
|
||||
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
|
||||
@@ -1248,7 +1256,8 @@
|
||||
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
|
||||
"update_personal_info": "個人情報を更新",
|
||||
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
|
||||
"warning_cannot_undo": "この操作は元に戻せません"
|
||||
"warning_cannot_undo": "この操作は元に戻せません",
|
||||
"wrong_password": "パスワードが間違っています"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "割り当て =",
|
||||
"audience": "オーディエンス",
|
||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
|
||||
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須の質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Verwijder mijn account",
|
||||
"confirm_your_current_password_to_get_started": "Bevestig uw huidige wachtwoord om aan de slag te gaan.",
|
||||
"delete_account": "Account verwijderen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Schakel tweefactorauthenticatie uit",
|
||||
"disable_two_factor_authentication_description": "Als u 2FA moet uitschakelen, raden wij u aan dit zo snel mogelijk opnieuw in te schakelen.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Elke back-upcode kan precies één keer worden gebruikt om toegang te verlenen zonder uw authenticator.",
|
||||
"email_change_initiated": "Uw verzoek tot wijziging van het e-mailadres is ingediend.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Toegang verloren",
|
||||
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
|
||||
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
|
||||
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
|
||||
"sso_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
|
||||
"two_factor_authentication": "Tweefactorauthenticatie",
|
||||
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Toewijzen =",
|
||||
"audience": "Publiek",
|
||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
|
||||
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
|
||||
"delete_account": "Excluir Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
|
||||
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Eliminar a Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
"delete_account": "Eliminar Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
|
||||
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar poderá redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Șterge contul meu",
|
||||
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
|
||||
"delete_account": "Șterge cont",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
|
||||
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
|
||||
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acces pierdut",
|
||||
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
|
||||
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
|
||||
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
|
||||
"sso_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters automat.",
|
||||
"two_factor_authentication": "Autentificare în doi pași",
|
||||
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Atribuire =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
|
||||
@@ -828,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": "Вы уже подключили другой опрос к этому каналу.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Удалить мой аккаунт",
|
||||
"confirm_your_current_password_to_get_started": "Подтвердите текущий пароль, чтобы начать.",
|
||||
"delete_account": "Удалить аккаунт",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Отключить двухфакторную аутентификацию",
|
||||
"disable_two_factor_authentication_description": "Если вам нужно отключить 2FA, рекомендуем включить её снова как можно скорее.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Каждый резервный код можно использовать только один раз для доступа без аутентификатора.",
|
||||
"email_change_initiated": "Запрос на изменение электронной почты инициирован.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Потерян доступ",
|
||||
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
|
||||
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
|
||||
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
|
||||
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Для учетных записей SSO выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
|
||||
"two_factor_authentication": "Двухфакторная аутентификация",
|
||||
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
|
||||
@@ -1248,7 +1256,8 @@
|
||||
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
|
||||
"update_personal_info": "Обновить личную информацию",
|
||||
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
|
||||
"warning_cannot_undo": "Это действие необратимо"
|
||||
"warning_cannot_undo": "Это действие необратимо",
|
||||
"wrong_password": "Неверный пароль"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Добавьте участников в команду и определите их роль.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Назначить =",
|
||||
"audience": "Аудитория",
|
||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
|
||||
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
|
||||
@@ -828,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.",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "Ta bort mitt konto",
|
||||
"confirm_your_current_password_to_get_started": "Bekräfta ditt nuvarande lösenord för att komma igång.",
|
||||
"delete_account": "Ta bort konto",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Inaktivera tvåfaktorsautentisering",
|
||||
"disable_two_factor_authentication_description": "Om du behöver inaktivera 2FA rekommenderar vi att du aktiverar det igen så snart som möjligt.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Varje reservkod kan användas exakt en gång för att ge åtkomst utan din autentiserare.",
|
||||
"email_change_initiated": "Din begäran om e-poständring har initierats.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Förlorad åtkomst",
|
||||
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
|
||||
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
|
||||
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
|
||||
"sso_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort automatiskt.",
|
||||
"two_factor_authentication": "Tvåfaktorsautentisering",
|
||||
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
|
||||
@@ -1248,7 +1256,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.",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "Tilldela =",
|
||||
"audience": "Målgrupp",
|
||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
|
||||
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
|
||||
@@ -828,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": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "删除我的账户",
|
||||
"confirm_your_current_password_to_get_started": "确认 您 的 当前 密码 以 开始。",
|
||||
"delete_account": "删除账号",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "禁用 双因素 认证",
|
||||
"disable_two_factor_authentication_description": "如果你需要禁用 2FA ,我们建议尽快重新启用。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每个备用代码只能使用一次,以在没有您的身份验证器的情况下授予访问权限。",
|
||||
"email_change_initiated": "您的 邮箱 更改 请求 已启动。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "启用 双因素 认证",
|
||||
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "失去访问",
|
||||
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
|
||||
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
|
||||
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
|
||||
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除您的账号。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将您重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
|
||||
"two_factor_authentication": "双因素 认证",
|
||||
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
|
||||
@@ -1248,7 +1256,8 @@
|
||||
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
|
||||
"update_personal_info": "更新你的个人信息",
|
||||
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
|
||||
"warning_cannot_undo": "此 无法 撤销。"
|
||||
"warning_cannot_undo": "此 无法 撤销。",
|
||||
"wrong_password": "密码错误"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "指派 =",
|
||||
"audience": "受众",
|
||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
|
||||
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
|
||||
@@ -828,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": "您已將另一個問卷連線到此頻道。",
|
||||
@@ -1225,12 +1228,15 @@
|
||||
"confirm_delete_my_account": "刪除我的帳戶",
|
||||
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
|
||||
"delete_account": "刪除帳戶",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "無法存取",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
@@ -1241,6 +1247,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
|
||||
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
|
||||
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除您的帳號。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "對於 SSO 帳戶,選取刪除可能會將您重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
|
||||
"two_factor_authentication": "雙重驗證",
|
||||
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
|
||||
@@ -1248,7 +1256,8 @@
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"wrong_password": "密碼錯誤"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1359,8 +1368,6 @@
|
||||
"assign": "等於 =",
|
||||
"audience": "受眾",
|
||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
|
||||
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
|
||||
@@ -1,24 +1,76 @@
|
||||
"use server";
|
||||
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { startAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
|
||||
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";
|
||||
|
||||
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 ZDeleteUserConfirmation = z
|
||||
.object({
|
||||
confirmationEmail: z.string().trim().pipe(ZUserEmail),
|
||||
password: z.string().max(128).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ZStartAccountDeletionSsoReauth = z
|
||||
.object({
|
||||
confirmationEmail: z.string().trim().pipe(ZUserEmail),
|
||||
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const logAccountDeletionError = (userId: string, error: unknown) => {
|
||||
logger.error({ error, userId }, "Account deletion failed");
|
||||
};
|
||||
|
||||
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
|
||||
.inputSchema(ZStartAccountDeletionSsoReauth)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
|
||||
const { confirmationEmail, returnToUrl } = parsedInput;
|
||||
|
||||
return await startAccountDeletionSsoReauthentication({
|
||||
confirmationEmail,
|
||||
returnToUrl,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUserConfirmation).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 { confirmationEmail, password } = parsedInput;
|
||||
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail,
|
||||
password,
|
||||
userEmail: ctx.user.email,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
ctx.auditLoggingCtx.oldObject = oldUser;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "delete_account");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
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 {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
} from "@/modules/account/constants";
|
||||
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 { deleteUserAction } from "./actions";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
requiresPasswordConfirmation: boolean;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
@@ -19,24 +32,119 @@ interface DeleteAccountModalProps {
|
||||
}
|
||||
|
||||
export const DeleteAccountModal = ({
|
||||
requiresPasswordConfirmation,
|
||||
setOpen,
|
||||
open,
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
organizationsWithSingleOwner,
|
||||
}: DeleteAccountModalProps) => {
|
||||
}: Readonly<DeleteAccountModalProps>) => {
|
||||
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 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 && (!requiresPasswordConfirmation || password.length > 0);
|
||||
const isDeleteDisabled = !hasValidConfirmation;
|
||||
const getLocalizedDeletionErrorMessage = (serverError?: string) => {
|
||||
if (serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
|
||||
return t("environments.settings.profile.wrong_password");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
return t("environments.settings.profile.google_sso_account_deletion_requires_setup");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
|
||||
return t("environments.settings.profile.email_confirmation_does_not_match");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE) {
|
||||
return t("environments.settings.profile.delete_account_confirmation_required");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
|
||||
return t("environments.settings.profile.sso_reauthentication_failed");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const startSsoReauthentication = async () => {
|
||||
const result = await startAccountDeletionSsoReauthenticationAction({
|
||||
confirmationEmail: inputValue,
|
||||
returnToUrl: globalThis.location.href,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
|
||||
const errorMessage =
|
||||
getLocalizedDeletionErrorMessage(result?.serverError) ??
|
||||
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn(
|
||||
result.data.provider,
|
||||
{
|
||||
callbackUrl: result.data.callbackUrl,
|
||||
redirect: true,
|
||||
},
|
||||
result.data.authorizationParams
|
||||
);
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
const result = await deleteUserAction(
|
||||
requiresPasswordConfirmation
|
||||
? {
|
||||
confirmationEmail: inputValue,
|
||||
password,
|
||||
}
|
||||
: {
|
||||
confirmationEmail: inputValue,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result?.data?.success) {
|
||||
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
|
||||
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
|
||||
|
||||
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
|
||||
await startSsoReauthentication();
|
||||
return;
|
||||
} else if (result) {
|
||||
errorMessage =
|
||||
getLocalizedDeletionErrorMessage(result.serverError) ?? 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({
|
||||
@@ -47,27 +155,27 @@ export const DeleteAccountModal = ({
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
if (isFormbricksCloud) {
|
||||
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
|
||||
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
} else {
|
||||
window.location.replace("/auth/login");
|
||||
globalThis.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 +218,34 @@ export const DeleteAccountModal = ({
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={user.email}
|
||||
className="mt-5"
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteAccountConfirmation"
|
||||
name="deleteAccountConfirmation"
|
||||
/>
|
||||
{!requiresPasswordConfirmation && (
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{t("environments.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
|
||||
</p>
|
||||
)}
|
||||
{requiresPasswordConfirmation && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH = "/auth/account-deletion/sso/complete";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM = "accountDeletionError";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
|
||||
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
|
||||
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
|
||||
export const ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
|
||||
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
|
||||
|
||||
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
|
||||
@@ -0,0 +1,8 @@
|
||||
import "server-only";
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
type TAccountDeletionPasswordAuthData = Pick<User, "identityProvider">;
|
||||
|
||||
export const requiresPasswordConfirmationForAccountDeletion = ({
|
||||
identityProvider,
|
||||
}: TAccountDeletionPasswordAuthData): boolean => identityProvider === "email";
|
||||
@@ -0,0 +1,665 @@
|
||||
import "server-only";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Account } from "next-auth";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { cache } from "@/lib/cache";
|
||||
import {
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getUserAuthenticationData } from "@/lib/user/password";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import {
|
||||
getSsoProviderLookupCandidates,
|
||||
normalizeSsoProvider,
|
||||
} from "@/modules/ee/sso/lib/provider-normalization";
|
||||
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
|
||||
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
|
||||
|
||||
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
|
||||
type TAccountWithSamlAuthnInstant = Account & {
|
||||
authn_instant?: unknown;
|
||||
};
|
||||
|
||||
type TStoredAccountDeletionSsoReauthIntent = {
|
||||
id: string;
|
||||
provider: TSsoIdentityProvider;
|
||||
providerAccountId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthMarker = TStoredAccountDeletionSsoReauthIntent & {
|
||||
completedAt: number;
|
||||
};
|
||||
|
||||
type TStartAccountDeletionSsoReauthenticationInput = {
|
||||
confirmationEmail: string;
|
||||
returnToUrl: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type TStartAccountDeletionSsoReauthenticationResult = {
|
||||
authorizationParams: Record<string, string>;
|
||||
callbackUrl: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
|
||||
azuread: "azure-ad",
|
||||
github: "github",
|
||||
google: "google",
|
||||
openid: "openid",
|
||||
saml: "saml",
|
||||
} as const satisfies Record<TSsoIdentityProvider, string>;
|
||||
|
||||
const getOidcReauthProviders = () =>
|
||||
new Set<TSsoIdentityProvider>([
|
||||
"azuread",
|
||||
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
|
||||
"openid",
|
||||
]);
|
||||
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
|
||||
// destructive action without another app-controlled step-up.
|
||||
const getFreshSsoReauthProviders = () => new Set<TSsoIdentityProvider>([...getOidcReauthProviders(), "saml"]);
|
||||
// Google only returns auth_time when it is explicitly requested as an ID token claim.
|
||||
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
|
||||
id_token: {
|
||||
auth_time: {
|
||||
essential: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
|
||||
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
|
||||
|
||||
const getAccountDeletionSsoReauthMarkerKey = (userId: string) =>
|
||||
createCacheKey.custom("account_deletion", userId, "sso_reauth_complete");
|
||||
|
||||
const getSsoIdentityProviderOrThrow = (
|
||||
identityProvider: IdentityProvider,
|
||||
providerAccountId: string | null
|
||||
): { provider: TSsoIdentityProvider; providerAccountId: string } => {
|
||||
if (identityProvider === "email" || !providerAccountId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return { provider: identityProvider, providerAccountId };
|
||||
};
|
||||
|
||||
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
|
||||
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
|
||||
logger.warn(
|
||||
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
|
||||
"Google SSO account deletion reauthentication is not enabled"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
|
||||
}
|
||||
|
||||
if (!getFreshSsoReauthProviders().has(provider)) {
|
||||
logger.warn(
|
||||
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
|
||||
"SSO provider does not support verifiable account deletion reauthentication"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthAuthorizationParams = (
|
||||
provider: TSsoIdentityProvider,
|
||||
email: string
|
||||
): Record<string, string> => {
|
||||
if (provider === "saml") {
|
||||
return {
|
||||
forceAuthn: "true",
|
||||
product: SAML_PRODUCT,
|
||||
provider: "saml",
|
||||
tenant: SAML_TENANT,
|
||||
};
|
||||
}
|
||||
|
||||
if (getOidcReauthProviders().has(provider)) {
|
||||
if (provider === "google") {
|
||||
return {
|
||||
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
|
||||
login_hint: email,
|
||||
max_age: "0",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
login_hint: email,
|
||||
max_age: "0",
|
||||
prompt: "login",
|
||||
};
|
||||
}
|
||||
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
};
|
||||
|
||||
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
|
||||
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
|
||||
callbackUrl.searchParams.set("intent", intentToken);
|
||||
return callbackUrl.toString();
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
|
||||
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
|
||||
}
|
||||
|
||||
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedCallbackUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedCallbackUrl = new URL(validatedCallbackUrl);
|
||||
|
||||
if (parsedCallbackUrl.pathname !== ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedCallbackUrl.searchParams.get("intent");
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
|
||||
error,
|
||||
intentToken,
|
||||
}: {
|
||||
error: unknown;
|
||||
intentToken: string | null;
|
||||
}): string | null => {
|
||||
if (!intentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(intent.returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(validatedReturnToUrl);
|
||||
redirectUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
getAccountDeletionSsoReauthErrorCode(error)
|
||||
);
|
||||
return redirectUrl.toString();
|
||||
} catch (redirectError) {
|
||||
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
|
||||
const cacheKey = getAccountDeletionSsoReauthIntentKey(intent.id);
|
||||
const result = await cache.set(cacheKey, intent, ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: intent.id, userId: intent.userId },
|
||||
"Failed to store SSO reauth intent"
|
||||
);
|
||||
throw new Error("Unable to start account deletion SSO reauthentication");
|
||||
}
|
||||
};
|
||||
|
||||
const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoReauthMarker) => {
|
||||
const cacheKey = getAccountDeletionSsoReauthMarkerKey(marker.userId);
|
||||
const result = await cache.set(cacheKey, marker, ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: marker.id, userId: marker.userId },
|
||||
"Failed to store account deletion SSO reauth marker"
|
||||
);
|
||||
throw new Error("Unable to complete account deletion SSO reauthentication");
|
||||
}
|
||||
};
|
||||
|
||||
const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
|
||||
let redis;
|
||||
|
||||
try {
|
||||
redis = await cache.getRedisClient();
|
||||
} catch (error) {
|
||||
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
|
||||
throw new Error("Unable to consume account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
try {
|
||||
const serializedValue = await redis.eval(
|
||||
`
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return value
|
||||
`,
|
||||
{
|
||||
arguments: [],
|
||||
keys: [key],
|
||||
}
|
||||
);
|
||||
|
||||
if (serializedValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof serializedValue !== "string") {
|
||||
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
|
||||
throw new Error("Unexpected cached account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
return JSON.parse(serializedValue) as TValue;
|
||||
} catch (error) {
|
||||
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
|
||||
const cacheResult = await cache.get<TValue>(key);
|
||||
|
||||
if (!cacheResult.ok) {
|
||||
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
|
||||
throw new Error("Unable to read account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
return cacheResult.data;
|
||||
};
|
||||
|
||||
const assertStoredAccountDeletionSsoReauthIntentMatches = (
|
||||
cachedIntent: TStoredAccountDeletionSsoReauthIntent | null,
|
||||
intent: TStoredAccountDeletionSsoReauthIntent
|
||||
) => {
|
||||
if (
|
||||
cachedIntent?.userId !== intent.userId ||
|
||||
cachedIntent?.provider !== intent.provider ||
|
||||
cachedIntent?.providerAccountId !== intent.providerAccountId
|
||||
) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const consumeStoredAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
|
||||
const cachedIntent = await consumeCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
|
||||
getAccountDeletionSsoReauthIntentKey(intent.id),
|
||||
{
|
||||
intentId: intent.id,
|
||||
userId: intent.userId,
|
||||
}
|
||||
);
|
||||
|
||||
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
|
||||
};
|
||||
|
||||
const assertStoredAccountDeletionSsoReauthIntentExists = async (
|
||||
intent: TStoredAccountDeletionSsoReauthIntent
|
||||
) => {
|
||||
const cachedIntent = await getCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
|
||||
getAccountDeletionSsoReauthIntentKey(intent.id),
|
||||
{
|
||||
intentId: intent.id,
|
||||
userId: intent.userId,
|
||||
}
|
||||
);
|
||||
|
||||
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
|
||||
};
|
||||
|
||||
const findLinkedSsoUserId = async ({
|
||||
provider,
|
||||
providerAccountId,
|
||||
}: {
|
||||
provider: TSsoIdentityProvider;
|
||||
providerAccountId: string;
|
||||
}) => {
|
||||
const lookupCandidates = getSsoProviderLookupCandidates(provider);
|
||||
|
||||
for (const lookupProvider of lookupCandidates) {
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: lookupProvider,
|
||||
providerAccountId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (account) {
|
||||
return account.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: providerAccountId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return legacyUser?.id ?? null;
|
||||
};
|
||||
|
||||
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
|
||||
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
|
||||
|
||||
if (isTooOld || isFromTheFuture) {
|
||||
logger.warn(
|
||||
{
|
||||
...logContext,
|
||||
ageSeconds: nowInSeconds - authTimeInSeconds,
|
||||
authTimeInSeconds,
|
||||
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
|
||||
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
|
||||
},
|
||||
"SSO account deletion reauthentication timestamp is not fresh"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
|
||||
if (!getOidcReauthProviders().has(provider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const decodedToken = jwt.decode(idToken);
|
||||
|
||||
if (!decodedToken || typeof decodedToken === "string") {
|
||||
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const { auth_time: authTime } = decodedToken;
|
||||
|
||||
if (typeof authTime !== "number") {
|
||||
logger.warn(
|
||||
{ claimKeys: Object.keys(decodedToken), provider },
|
||||
"OIDC account deletion reauthentication callback is missing numeric auth_time"
|
||||
);
|
||||
if (provider === "google") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
|
||||
}
|
||||
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
|
||||
};
|
||||
|
||||
const assertFreshSamlAuthnInstant = (
|
||||
provider: TSsoIdentityProvider,
|
||||
account: TAccountWithSamlAuthnInstant
|
||||
) => {
|
||||
if (provider !== "saml") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof account.authn_instant !== "string") {
|
||||
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const authnInstantTimestamp = Date.parse(account.authn_instant);
|
||||
|
||||
if (Number.isNaN(authnInstantTimestamp)) {
|
||||
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
|
||||
};
|
||||
|
||||
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
assertFreshOidcAuthTime(provider, account.id_token);
|
||||
assertFreshSamlAuthnInstant(provider, account);
|
||||
};
|
||||
|
||||
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
|
||||
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
const provider = normalizeSsoProvider(intent.provider);
|
||||
|
||||
if (!provider || provider === "email") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
|
||||
return {
|
||||
intent,
|
||||
storedIntent: {
|
||||
id: intent.id,
|
||||
provider,
|
||||
providerAccountId: intent.providerAccountId,
|
||||
userId: intent.userId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedSsoProviderFromAccount = (account: Account) => {
|
||||
const normalizedProvider = normalizeSsoProvider(account.provider);
|
||||
|
||||
if (!normalizedProvider || normalizedProvider === "email") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return normalizedProvider;
|
||||
};
|
||||
|
||||
const assertAccountMatchesIntent = ({
|
||||
account,
|
||||
expectedProvider,
|
||||
expectedProviderAccountId,
|
||||
provider,
|
||||
}: {
|
||||
account: Account;
|
||||
expectedProvider: TSsoIdentityProvider;
|
||||
expectedProviderAccountId: string;
|
||||
provider: TSsoIdentityProvider;
|
||||
}) => {
|
||||
if (provider !== expectedProvider || account.providerAccountId !== expectedProviderAccountId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
const { intent, storedIntent } = getVerifiedAccountDeletionSsoReauthIntent(intentToken);
|
||||
const normalizedProvider = getNormalizedSsoProviderFromAccount(account);
|
||||
|
||||
assertAccountMatchesIntent({
|
||||
account,
|
||||
expectedProvider: storedIntent.provider,
|
||||
expectedProviderAccountId: storedIntent.providerAccountId,
|
||||
provider: normalizedProvider,
|
||||
});
|
||||
assertFreshSsoAuthentication(normalizedProvider, account);
|
||||
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
|
||||
|
||||
return { intent, normalizedProvider, storedIntent };
|
||||
};
|
||||
|
||||
export const startAccountDeletionSsoReauthentication = async ({
|
||||
confirmationEmail,
|
||||
returnToUrl,
|
||||
userId,
|
||||
}: TStartAccountDeletionSsoReauthenticationInput): Promise<TStartAccountDeletionSsoReauthenticationResult> => {
|
||||
const userAuthenticationData = await getUserAuthenticationData(userId);
|
||||
|
||||
if (confirmationEmail.toLowerCase() !== userAuthenticationData.email.toLowerCase()) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
|
||||
}
|
||||
|
||||
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const { provider, providerAccountId } = getSsoIdentityProviderOrThrow(
|
||||
userAuthenticationData.identityProvider,
|
||||
userAuthenticationData.identityProviderAccountId
|
||||
);
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
|
||||
|
||||
const intentId = crypto.randomUUID();
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
|
||||
|
||||
await storeAccountDeletionSsoReauthIntent({
|
||||
id: intentId,
|
||||
provider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const intentToken = createAccountDeletionSsoReauthIntent({
|
||||
id: intentId,
|
||||
email: userAuthenticationData.email,
|
||||
provider,
|
||||
providerAccountId,
|
||||
purpose: "account_deletion_sso_reauth",
|
||||
returnToUrl: validatedReturnToUrl,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
authorizationParams: getAccountDeletionSsoReauthAuthorizationParams(
|
||||
provider,
|
||||
userAuthenticationData.email
|
||||
),
|
||||
callbackUrl: createAccountDeletionSsoReauthCallbackUrl(intentToken),
|
||||
provider: NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER[provider],
|
||||
};
|
||||
};
|
||||
|
||||
export const completeAccountDeletionSsoReauthentication = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
const { intent, normalizedProvider, storedIntent } =
|
||||
await validateAccountDeletionSsoReauthenticationCallbackContext({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
|
||||
const linkedUserId = await findLinkedSsoUserId({
|
||||
provider: normalizedProvider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
|
||||
if (linkedUserId !== intent.userId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
await consumeStoredAccountDeletionSsoReauthIntent(storedIntent);
|
||||
|
||||
await storeAccountDeletionSsoReauthMarker({
|
||||
completedAt: Date.now(),
|
||||
id: intent.id,
|
||||
provider: normalizedProvider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
userId: intent.userId,
|
||||
});
|
||||
logger.info(
|
||||
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
|
||||
"Completed account deletion SSO reauthentication"
|
||||
);
|
||||
};
|
||||
|
||||
export const validateAccountDeletionSsoReauthenticationCallback = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
await validateAccountDeletionSsoReauthenticationCallbackContext({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
};
|
||||
|
||||
export const consumeAccountDeletionSsoReauthentication = async ({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
}: {
|
||||
identityProvider: IdentityProvider;
|
||||
providerAccountId: string | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
const { provider, providerAccountId: ssoProviderAccountId } = getSsoIdentityProviderOrThrow(
|
||||
identityProvider,
|
||||
providerAccountId
|
||||
);
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
|
||||
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
|
||||
getAccountDeletionSsoReauthMarkerKey(userId),
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (
|
||||
marker?.userId !== userId ||
|
||||
marker?.provider !== provider ||
|
||||
marker?.providerAccountId !== ssoProviderAccountId ||
|
||||
Date.now() - (marker?.completedAt ?? 0) > ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS
|
||||
) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import "server-only";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { DISABLE_ACCOUNT_DELETION_SSO_REAUTH } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
|
||||
} from "@/modules/account/constants";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { consumeAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const getPasswordOrThrow = (password?: string) => {
|
||||
if (!password) {
|
||||
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return password;
|
||||
};
|
||||
|
||||
const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail: string) => {
|
||||
if (confirmationEmail.toLowerCase() !== expectedEmail.toLowerCase()) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
|
||||
|
||||
const assertAccountDeletionSsoReauthentication = async ({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
}: {
|
||||
identityProvider: IdentityProvider;
|
||||
providerAccountId: string | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (canBypassSsoReauthentication(identityProvider)) {
|
||||
logger.warn(
|
||||
{ identityProvider, userId },
|
||||
"Account deletion SSO reauthentication bypassed by environment configuration"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await consumeAccountDeletionSsoReauthentication({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteUserWithAccountDeletionAuthorization = async ({
|
||||
confirmationEmail,
|
||||
password,
|
||||
userEmail,
|
||||
userId,
|
||||
}: {
|
||||
confirmationEmail: string;
|
||||
password?: string;
|
||||
userEmail: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
assertConfirmationEmailMatches(confirmationEmail, userEmail);
|
||||
|
||||
const userAuthenticationData = await getUserAuthenticationData(userId);
|
||||
assertConfirmationEmailMatches(confirmationEmail, userAuthenticationData.email);
|
||||
|
||||
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
const isCorrectPassword = await verifyUserPassword(userId, getPasswordOrThrow(password));
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) {
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(userId);
|
||||
if (organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const oldUser = await getUser(userId);
|
||||
if (!oldUser) {
|
||||
throw new AuthorizationError("User not found");
|
||||
}
|
||||
|
||||
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
await assertAccountDeletionSsoReauthentication({
|
||||
identityProvider: userAuthenticationData.identityProvider,
|
||||
providerAccountId: userAuthenticationData.identityProviderAccountId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
|
||||
return { oldUser };
|
||||
};
|
||||
@@ -13,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" }] } }],
|
||||
@@ -60,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
completeAccountDeletionSsoReauthentication,
|
||||
getAccountDeletionSsoReauthFailureRedirectUrl,
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl,
|
||||
validateAccountDeletionSsoReauthenticationCallback,
|
||||
} from "@/modules/account/lib/account-deletion-sso-reauth";
|
||||
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import {
|
||||
@@ -336,6 +342,8 @@ export const authOptions: NextAuthOptions = {
|
||||
// get callback url from the cookie store,
|
||||
const callbackUrl =
|
||||
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
|
||||
const accountDeletionSsoReauthIntentToken =
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
|
||||
|
||||
const userEmail = user.email ?? "";
|
||||
const userId = user.id as string;
|
||||
@@ -373,17 +381,44 @@ export const authOptions: NextAuthOptions = {
|
||||
return true;
|
||||
}
|
||||
if (ENTERPRISE_LICENSE_KEY && account) {
|
||||
const result = await handleSsoCallback({
|
||||
user: user as TUser,
|
||||
account,
|
||||
callbackUrl,
|
||||
});
|
||||
try {
|
||||
if (accountDeletionSsoReauthIntentToken) {
|
||||
await validateAccountDeletionSsoReauthenticationCallback({
|
||||
account,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
const result = await handleSsoCallback({
|
||||
user: user as TUser,
|
||||
account,
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (accountDeletionSsoReauthIntentToken) {
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
}
|
||||
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
|
||||
error,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
|
||||
if (failureRedirectUrl) {
|
||||
return failureRedirectUrl;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void captureSignIn(account?.provider ?? "unknown");
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
|
||||
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual([
|
||||
"emailUpdate",
|
||||
"accountDeletion",
|
||||
"surveyFollowUp",
|
||||
"sendLinkSurveyEmail",
|
||||
"licenseRecheck",
|
||||
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
|
||||
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
|
||||
// Server actions - varies by action type
|
||||
actions: {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
sendLinkSurveyEmail: {
|
||||
interval: 3600,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { storeSamlAuthnInstantFromSamlResponse } from "@/modules/ee/auth/saml/lib/authn-instant";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
interface SAMLCallbackBody {
|
||||
@@ -12,7 +14,7 @@ export const POST = async (req: Request) => {
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const { connectionController, oauthController } = jacksonInstance;
|
||||
|
||||
const formData = await req.formData();
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
@@ -28,5 +30,15 @@ export const POST = async (req: Request) => {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
try {
|
||||
await storeSamlAuthnInstantFromSamlResponse({
|
||||
connectionController,
|
||||
redirectUrl: redirect_url,
|
||||
samlResponse: SAMLResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to persist SAML AuthnInstant");
|
||||
}
|
||||
|
||||
return redirect(redirect_url);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { consumeSamlAuthnInstantForCode } from "@/modules/ee/auth/saml/lib/authn-instant";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -13,6 +15,13 @@ export const POST = async (req: Request) => {
|
||||
const formData = Object.fromEntries(body.entries());
|
||||
|
||||
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
|
||||
let authnInstant: string | null = null;
|
||||
|
||||
return Response.json(response);
|
||||
try {
|
||||
authnInstant = await consumeSamlAuthnInstantForCode(formData.code);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to consume SAML AuthnInstant");
|
||||
}
|
||||
|
||||
return Response.json(authnInstant ? { ...response, authn_instant: authnInstant } : response);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import "server-only";
|
||||
import saml20 from "@boxyhq/saml20";
|
||||
import type { IConnectionAPIController, SAMLSSORecord } from "@boxyhq/saml-jackson";
|
||||
import { getDefaultCertificate } from "@boxyhq/saml-jackson/dist/saml/x509";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
|
||||
const SAML_AUTHN_INSTANT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type TSamlAuthnInstantCacheValue = {
|
||||
authnInstant: string;
|
||||
};
|
||||
type TSamlConnection = Awaited<ReturnType<IConnectionAPIController["getConnections"]>>[number];
|
||||
|
||||
const authnInstantRegex = /<[\w:-]*AuthnStatement\b[^>]*\bAuthnInstant\s*=\s*["']([^"']+)["']/;
|
||||
const encryptedAssertionRegex = /<[\w:-]*EncryptedAssertion\b/;
|
||||
|
||||
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
|
||||
|
||||
const getSamlAuthnInstantCacheKey = (code: string) =>
|
||||
createCacheKey.custom("account_deletion", "saml_authn_instant", getSamlCodeHash(code));
|
||||
|
||||
const isSamlConnection = (connection: TSamlConnection): connection is SAMLSSORecord =>
|
||||
"idpMetadata" in connection;
|
||||
|
||||
const getCodeFromRedirectUrl = (redirectUrl: string) => {
|
||||
try {
|
||||
return new URL(redirectUrl).searchParams.get("code");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSamlAuthnInstantFromXml = (samlXml: string): string | null => {
|
||||
// Use .exec() instead of .match()
|
||||
const match = authnInstantRegex.exec(samlXml);
|
||||
const authnInstant = match?.[1];
|
||||
|
||||
if (!authnInstant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authnInstantTimestamp = Date.parse(authnInstant);
|
||||
|
||||
if (Number.isNaN(authnInstantTimestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(authnInstantTimestamp).toISOString();
|
||||
};
|
||||
|
||||
const getSignedSamlXml = async ({
|
||||
connectionController,
|
||||
decodedSamlResponse,
|
||||
}: {
|
||||
connectionController: IConnectionAPIController;
|
||||
decodedSamlResponse: string;
|
||||
}) => {
|
||||
const issuer = saml20.parseIssuer(decodedSamlResponse);
|
||||
|
||||
if (!issuer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connections = await connectionController.getConnections({ entityId: issuer });
|
||||
|
||||
for (const connection of connections) {
|
||||
if (!isSamlConnection(connection)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { publicKey, thumbprint } = connection.idpMetadata;
|
||||
|
||||
if (!publicKey && !thumbprint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const signedXml = saml20.validateSignature(decodedSamlResponse, publicKey ?? null, thumbprint ?? null);
|
||||
|
||||
if (signedXml) {
|
||||
return signedXml;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getReadableSignedSamlXml = async (signedSamlXml: string) => {
|
||||
if (!encryptedAssertionRegex.test(signedSamlXml)) {
|
||||
return signedSamlXml;
|
||||
}
|
||||
|
||||
const { privateKey } = await getDefaultCertificate();
|
||||
return saml20.decryptXml(signedSamlXml, { privateKey }).assertion;
|
||||
};
|
||||
|
||||
export const getSamlAuthnInstantFromResponse = async ({
|
||||
connectionController,
|
||||
samlResponse,
|
||||
}: {
|
||||
connectionController: IConnectionAPIController;
|
||||
samlResponse: string;
|
||||
}): Promise<string | null> => {
|
||||
const decodedSamlResponse = Buffer.from(samlResponse, "base64").toString("utf8");
|
||||
const signedSamlXml = await getSignedSamlXml({
|
||||
connectionController,
|
||||
decodedSamlResponse,
|
||||
});
|
||||
|
||||
if (!signedSamlXml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getSamlAuthnInstantFromXml(await getReadableSignedSamlXml(signedSamlXml));
|
||||
};
|
||||
|
||||
export const storeSamlAuthnInstantFromSamlResponse = async ({
|
||||
connectionController,
|
||||
redirectUrl,
|
||||
samlResponse,
|
||||
}: {
|
||||
connectionController: IConnectionAPIController;
|
||||
redirectUrl: string;
|
||||
samlResponse: string;
|
||||
}) => {
|
||||
const code = getCodeFromRedirectUrl(redirectUrl);
|
||||
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authnInstant = await getSamlAuthnInstantFromResponse({
|
||||
connectionController,
|
||||
samlResponse,
|
||||
}).catch((error: unknown) => {
|
||||
logger.error({ error }, "Failed to extract SAML AuthnInstant");
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!authnInstant) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await cache.set(
|
||||
getSamlAuthnInstantCacheKey(code),
|
||||
{ authnInstant },
|
||||
SAML_AUTHN_INSTANT_TTL_MS
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error({ error: result.error }, "Failed to store SAML AuthnInstant");
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeSamlAuthnInstantForCode = async (code: unknown): Promise<string | null> => {
|
||||
if (typeof code !== "string" || !code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = getSamlAuthnInstantCacheKey(code);
|
||||
const result = await cache.get<TSamlAuthnInstantCacheValue>(cacheKey);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error({ error: result.error }, "Failed to read SAML AuthnInstant");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deleteResult = await cache.del([cacheKey]);
|
||||
|
||||
if (!deleteResult.ok) {
|
||||
logger.error({ error: deleteResult.error }, "Failed to consume SAML AuthnInstant");
|
||||
}
|
||||
|
||||
return result.data.authnInstant;
|
||||
};
|
||||
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="w-full items-center justify-center gap-2 px-2">
|
||||
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
|
||||
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
className="relative w-full justify-center">
|
||||
{text ? text : t("auth.continue_with_openid")}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
|
||||
const SSO_PROVIDER_MAP = {
|
||||
google: "google",
|
||||
github: "github",
|
||||
"azure-ad": "azuread",
|
||||
azuread: "azuread",
|
||||
openid: "openid",
|
||||
saml: "saml",
|
||||
} as const satisfies Record<string, IdentityProvider>;
|
||||
|
||||
const LEGACY_SSO_PROVIDER_ALIASES: Partial<Record<IdentityProvider, string[]>> = {
|
||||
azuread: ["azure-ad"],
|
||||
};
|
||||
|
||||
const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP =>
|
||||
provider in SSO_PROVIDER_MAP;
|
||||
|
||||
export const normalizeSsoProvider = (provider: string): IdentityProvider | null => {
|
||||
const normalizedProviderKey = provider.toLowerCase();
|
||||
if (!isSupportedSsoProvider(normalizedProviderKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SSO_PROVIDER_MAP[normalizedProviderKey];
|
||||
};
|
||||
|
||||
export const getLegacySsoProviderAliases = (provider: IdentityProvider): string[] =>
|
||||
LEGACY_SSO_PROVIDER_ALIASES[provider] ?? [];
|
||||
|
||||
export const getSsoProviderLookupCandidates = (provider: string): string[] => {
|
||||
const normalizedProvider = normalizeSsoProvider(provider);
|
||||
|
||||
if (!normalizedProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [normalizedProvider, ...getLegacySsoProviderAliases(normalizedProvider)];
|
||||
};
|
||||
@@ -51,7 +51,7 @@ describe("SSO Providers", () => {
|
||||
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
||||
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
|
||||
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
|
||||
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,6 @@ const LINKED_SSO_LOOKUP_SELECT = {
|
||||
identityProviderAccountId: true,
|
||||
} as const;
|
||||
|
||||
const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
|
||||
|
||||
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
|
||||
await upsertAccount(
|
||||
{
|
||||
@@ -219,7 +217,7 @@ export const handleSsoCallback = async ({
|
||||
}
|
||||
|
||||
// There is no existing linked account for this identity provider / account id
|
||||
// check if a user account with this email already exists and fail closed if so
|
||||
// check if a user account with this email already exists and auto-link it
|
||||
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
|
||||
|
||||
const existingUserWithEmail = await getUserByEmail(user.email);
|
||||
@@ -230,9 +228,10 @@ export const handleSsoCallback = async ({
|
||||
existingUserId: existingUserWithEmail.id,
|
||||
existingIdentityProvider: existingUserWithEmail.identityProvider,
|
||||
},
|
||||
"SSO callback blocked: existing user found by email without linked provider account"
|
||||
"SSO callback successful: existing user found by email"
|
||||
);
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
await syncSsoAccount(existingUserWithEmail.id, account);
|
||||
return true;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
|
||||
@@ -338,7 +338,7 @@ describe("handleSsoCallback", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject verified email users whose SSO provider is not already linked", async () => {
|
||||
test("should auto-link verified email users whose SSO provider is not already linked", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
id: "existing-user-id",
|
||||
@@ -349,22 +349,26 @@ describe("handleSsoCallback", () => {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
expect(upsertAccount).not.toHaveBeenCalled();
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(upsertAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "existing-user-id",
|
||||
provider: mockAccount.provider,
|
||||
providerAccountId: mockAccount.providerAccountId,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(createMembership).not.toHaveBeenCalled();
|
||||
expect(createBrevoCustomer).not.toHaveBeenCalled();
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject unverified email users whose SSO provider is not already linked", async () => {
|
||||
test("should auto-link unverified email users whose SSO provider is not already linked", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
@@ -376,22 +380,26 @@ describe("handleSsoCallback", () => {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
expect(upsertAccount).not.toHaveBeenCalled();
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(upsertAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "existing-user-id",
|
||||
provider: mockAccount.provider,
|
||||
providerAccountId: mockAccount.providerAccountId,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(createMembership).not.toHaveBeenCalled();
|
||||
expect(createBrevoCustomer).not.toHaveBeenCalled();
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject existing users from a different SSO provider when no link exists", async () => {
|
||||
test("should auto-link existing users from a different SSO provider when no link exists", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
@@ -403,14 +411,53 @@ describe("handleSsoCallback", () => {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
expect(upsertAccount).not.toHaveBeenCalled();
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(upsertAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "existing-user-id",
|
||||
provider: mockAccount.provider,
|
||||
providerAccountId: mockAccount.providerAccountId,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should auto-link same-email users even when the stored legacy provider account id is stale", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||
id: "existing-user-id",
|
||||
email: mockUser.email,
|
||||
emailVerified: new Date(),
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "old-provider-id",
|
||||
locale: mockUser.locale,
|
||||
isActive: true,
|
||||
} as any);
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(upsertAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "existing-user-id",
|
||||
provider: mockAccount.provider,
|
||||
providerAccountId: mockAccount.providerAccountId,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ export const AddWebhookModal = ({
|
||||
url: testEndpointInput,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
|
||||
if (!testEndpointActionResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
||||
throw new Error(errorMessage);
|
||||
|
||||
@@ -17,6 +17,14 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const constantsMock = vi.hoisted(() => ({ dangerouslyAllow: false }));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS() {
|
||||
return constantsMock.dangerouslyAllow;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
||||
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
||||
@@ -41,6 +49,7 @@ vi.mock("uuid", () => ({
|
||||
describe("testEndpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
constantsMock.dangerouslyAllow = false;
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
@@ -76,6 +85,36 @@ describe("testEndpoint", () => {
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([301, 302, 303, 307, 308])(
|
||||
"rejects %s redirects to prevent SSRF via redirect",
|
||||
async (statusCode) => {
|
||||
const fetchMock = vi.fn(async () => ({ status: statusCode }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
|
||||
"Webhook endpoint returned a redirect, which is not allowed"
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({ redirect: "manual" })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test("follows redirects when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
|
||||
constantsMock.dangerouslyAllow = true;
|
||||
const fetchMock = vi.fn(async () => ({ status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({ redirect: "follow" })
|
||||
);
|
||||
});
|
||||
|
||||
test("allows non-blocked non-2xx statuses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ResourceNotFoundError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
@@ -191,13 +192,29 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
);
|
||||
}
|
||||
|
||||
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
|
||||
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
|
||||
// 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 response = await fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
redirect: redirectMode,
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
|
||||
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
|
||||
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
|
||||
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
|
||||
// and this branch is unreachable.
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
|
||||
}
|
||||
|
||||
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
||||
|
||||
if (errorMessage) {
|
||||
|
||||
@@ -9,10 +9,15 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface RemovedFromOrganizationProps {
|
||||
isFormbricksCloud: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
user: TUser;
|
||||
}
|
||||
|
||||
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
|
||||
export const RemovedFromOrganization = ({
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<RemovedFromOrganizationProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
return (
|
||||
@@ -24,6 +29,7 @@ export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFrom
|
||||
<hr className="my-4 border-slate-200" />
|
||||
<p className="text-sm">{t("setup.organization.create.delete_account_description")}</p>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
user={user}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
|
||||
@@ -38,7 +39,13 @@ export const CreateOrganizationPage = async () => {
|
||||
}
|
||||
|
||||
if (userOrganizations.length === 0) {
|
||||
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||
return (
|
||||
<RemovedFromOrganization
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return notFound();
|
||||
|
||||
@@ -118,10 +118,6 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleAutoProgressToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, isAutoProgressingEnabled: !localSurvey.isAutoProgressingEnabled });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
@@ -388,13 +384,6 @@ export const ResponseOptionsCard = ({
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
)}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="autoProgressRatingNps"
|
||||
isChecked={Boolean(localSurvey.isAutoProgressingEnabled)}
|
||||
onToggle={handleAutoProgressToggle}
|
||||
title={t("environments.surveys.edit.auto_progress_rating_and_nps")}
|
||||
description={t("environments.surveys.edit.auto_progress_rating_and_nps_description")}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="hideBackButton"
|
||||
isChecked={localSurvey.isBackButtonHidden}
|
||||
|
||||
@@ -211,15 +211,11 @@ export const StylingView = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
id="overwrite-theme-styling"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={handleOverwriteToggle}
|
||||
/>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel htmlFor="overwrite-theme-styling" className="text-base font-semibold text-slate-900">
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
{t("environments.surveys.edit.add_custom_styles")}
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
|
||||
@@ -41,7 +41,6 @@ export const selectSurvey = {
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
metadata: true,
|
||||
slug: true,
|
||||
customHeadScripts: true,
|
||||
|
||||
@@ -79,7 +79,6 @@ describe("data", () => {
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
@@ -117,11 +116,6 @@ describe("data", () => {
|
||||
type: true,
|
||||
}),
|
||||
});
|
||||
expect(vi.mocked(prisma.survey.findUnique).mock.calls[0][0].select).toEqual(
|
||||
expect.objectContaining({
|
||||
isAutoProgressingEnabled: true,
|
||||
})
|
||||
);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyData);
|
||||
});
|
||||
|
||||
@@ -202,7 +196,6 @@ describe("data", () => {
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
@@ -47,7 +47,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
|
||||
// Single use configuration
|
||||
|
||||
@@ -41,7 +41,6 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
variables: [],
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isCaptureIpEnabled: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
|
||||
type SkeletonLoaderProps = {
|
||||
type: "response" | "responseTable" | "summary";
|
||||
type: "response" | "summary";
|
||||
};
|
||||
|
||||
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
@@ -25,43 +25,6 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "responseTable") {
|
||||
const renderTableCells = () => (
|
||||
<>
|
||||
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
|
||||
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "response") {
|
||||
return (
|
||||
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
|
||||
|
||||
+14
-13
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@boxyhq/saml20": "1.15.2",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
@@ -44,14 +45,14 @@
|
||||
"@lexical/rich-text": "0.41.0",
|
||||
"@lexical/table": "0.41.0",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.71.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.213.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.0",
|
||||
"@opentelemetry/sdk-node": "0.213.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.75.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.217.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-metrics": "2.7.1",
|
||||
"@opentelemetry/sdk-node": "0.217.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.2",
|
||||
@@ -94,14 +95,14 @@
|
||||
"jiti": "2.6.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lexical": "0.41.0",
|
||||
"lodash": "4.17.23",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "0.577.0",
|
||||
"markdown-it": "14.1.1",
|
||||
"next": "16.1.7",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "4.24.13",
|
||||
"next-safe-action": "8.1.8",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "8.0.2",
|
||||
"nodemailer": "8.0.7",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.5.3",
|
||||
"posthog-js": "1.360.0",
|
||||
@@ -127,7 +128,7 @@
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwindcss": "3.4.19",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "13.0.2",
|
||||
"webpack": "5.105.4",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "4.3.6",
|
||||
@@ -151,7 +152,7 @@
|
||||
"autoprefixer": "10.4.27",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"postcss": "8.5.8",
|
||||
"postcss": "8.5.14",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.describe("API Tests for Management Me", () => {
|
||||
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
|
||||
const name = `Security Me User ${Date.now()}`;
|
||||
const email = `security-me-${Date.now()}@example.com`;
|
||||
|
||||
try {
|
||||
const user = await users.create({ name, email });
|
||||
await user.login();
|
||||
|
||||
const response = await page.context().request.get("/api/v1/management/me");
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(responseBody).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name,
|
||||
email,
|
||||
twoFactorEnabled: expect.any(Boolean),
|
||||
identityProvider: expect.any(String),
|
||||
notificationSettings: expect.any(Object),
|
||||
locale: expect.any(String),
|
||||
isActive: expect.any(Boolean),
|
||||
});
|
||||
expect(responseBody).not.toHaveProperty("password");
|
||||
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during management me API security test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5631,9 +5631,6 @@ components:
|
||||
isBackButtonHidden:
|
||||
type: boolean
|
||||
description: Whether the back button is hidden
|
||||
isAutoProgressingEnabled:
|
||||
type: boolean
|
||||
description: Whether auto-progress is enabled for eligible question types
|
||||
recaptcha:
|
||||
type:
|
||||
- object
|
||||
@@ -5709,7 +5706,6 @@ components:
|
||||
- isSingleResponsePerEmailEnabled
|
||||
- inlineTriggers
|
||||
- isBackButtonHidden
|
||||
- isAutoProgressingEnabled
|
||||
- recaptcha
|
||||
- metadata
|
||||
- displayPercentage
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
---
|
||||
title: "Background Job Processing"
|
||||
description: "How BullMQ works in Formbricks today, including the migrated response pipeline workload."
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
This page documents the current BullMQ-based background job system in Formbricks and the first real workload that now runs on it: the response pipeline.
|
||||
|
||||
## Current State
|
||||
|
||||
Formbricks now uses BullMQ as an in-process background job system inside the Next.js web application.
|
||||
|
||||
The current implementation includes:
|
||||
|
||||
- a shared `@formbricks/jobs` package that owns queue creation, schemas, scheduling, and worker runtime concerns
|
||||
- a Next.js startup hook that starts one BullMQ worker runtime per Node.js process without blocking app boot
|
||||
- app-level enqueue helpers for request handlers
|
||||
- an app-owned BullMQ response pipeline processor that replaces the legacy internal HTTP pipeline route
|
||||
|
||||
The first migrated workload is:
|
||||
|
||||
- `response-pipeline.process`
|
||||
|
||||
This means response-related side effects no longer depend on an internal `fetch()` back into the same app process.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
The original response pipeline lived behind an internal Next.js route:
|
||||
|
||||
```text
|
||||
apps/web/app/api/(internal)/pipeline
|
||||
```
|
||||
|
||||
That model had a few problems:
|
||||
|
||||
- it was tightly coupled to the request lifecycle
|
||||
- it relied on an internal HTTP hop instead of a typed background-job boundary
|
||||
- it was harder to observe, retry, and scale safely
|
||||
|
||||
BullMQ addresses that by moving post-response work behind a queue while keeping the first version operationally simple for self-hosted users.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["API route or server code"] --> B["enqueueResponsePipelineEvents()"]
|
||||
B --> C["getResponseSnapshotForPipeline()"]
|
||||
B --> D["BackgroundJobProducer.enqueueResponsePipeline()"]
|
||||
D --> E["BullMQ queue: background-jobs"]
|
||||
F["instrumentation.ts"] --> G["registerJobsWorker()"]
|
||||
G --> H["startJobsRuntime()"]
|
||||
H --> I["BullMQ workers"]
|
||||
I --> J["response-pipeline.process override"]
|
||||
J --> K["processResponsePipelineJob()"]
|
||||
E --> I
|
||||
E --> L["Redis / Valkey"]
|
||||
I --> L
|
||||
```
|
||||
|
||||
## Responsibilities By Layer
|
||||
|
||||
### App Layer
|
||||
|
||||
- `apps/web/app/lib/pipelines.ts`
|
||||
Owns enqueueing for response pipeline events. It gates queueing, hydrates the response snapshot once, logs failures, and never throws back into request handlers.
|
||||
- `apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts`
|
||||
Owns app-specific execution of response-pipeline jobs.
|
||||
- `apps/web/modules/response-pipeline/lib/handle-integrations.ts`
|
||||
Owns Slack, Notion, Airtable, and Google Sheets integration fan-out for the pipeline.
|
||||
- `apps/web/modules/response-pipeline/lib/telemetry.ts`
|
||||
Owns telemetry dispatch logic used by the response-created path.
|
||||
- `apps/web/instrumentation-jobs.ts`
|
||||
Registers the app-owned response-pipeline handler override with the shared BullMQ runtime and schedules retry after transient startup failures.
|
||||
- `apps/web/lib/jobs/config.ts`
|
||||
Turns environment configuration into queueing and worker-bootstrap decisions. Queue producers depend on `REDIS_URL`; worker startup additionally depends on `BULLMQ_WORKER_ENABLED`.
|
||||
|
||||
### Shared Jobs Layer
|
||||
|
||||
- `packages/jobs/src/types.ts`
|
||||
Defines typed payload schemas such as `TResponsePipelineJobData`.
|
||||
- `packages/jobs/src/definitions.ts`
|
||||
Defines stable job names and payload validation.
|
||||
- `packages/jobs/src/queue.ts`
|
||||
Owns producer-side enqueueing and scheduling.
|
||||
- `packages/jobs/src/runtime.ts`
|
||||
Starts workers, connects Redis, and handles graceful shutdown.
|
||||
- `packages/jobs/src/processors/registry.ts`
|
||||
Validates payloads and dispatches named jobs, applying app-provided handler overrides when registered.
|
||||
|
||||
## Response Pipeline Flow
|
||||
|
||||
The response pipeline now runs fully in the background worker.
|
||||
|
||||
### Enqueueing
|
||||
|
||||
When a response is created or updated, the request path calls:
|
||||
|
||||
```ts
|
||||
enqueueResponsePipelineEvents({
|
||||
environmentId,
|
||||
surveyId,
|
||||
responseId,
|
||||
events,
|
||||
});
|
||||
```
|
||||
|
||||
That helper:
|
||||
|
||||
1. deduplicates requested events
|
||||
2. checks whether BullMQ queueing is enabled
|
||||
3. uses the just-written response snapshot when the caller already has it
|
||||
4. otherwise loads the latest response snapshot once via `getResponseSnapshotForPipeline(responseId)` using an uncached read
|
||||
5. enqueues one BullMQ job per event with the shared snapshot payload
|
||||
6. waits for the enqueue attempt to complete, then logs enqueue failures without failing the original request
|
||||
|
||||
### Execution
|
||||
|
||||
At worker startup, `apps/web/instrumentation-jobs.ts` registers an app-owned override for:
|
||||
|
||||
- `response-pipeline.process`
|
||||
|
||||
That override delegates to `processResponsePipelineJob(...)`, which performs:
|
||||
|
||||
- webhook delivery for all pipeline events
|
||||
- integrations for `responseFinished`
|
||||
- response-finished notification emails
|
||||
- follow-up delivery
|
||||
- survey auto-complete updates and audit logging
|
||||
- response-created billing metering
|
||||
- response-created telemetry dispatch
|
||||
|
||||
Current retry semantics are intentionally asymmetric:
|
||||
|
||||
- webhook delivery failures fail early BullMQ attempts so retries can happen at the job level
|
||||
- if webhook delivery is still failing on the final BullMQ attempt, the worker logs that retries are exhausted and continues with the remaining event-specific side effects
|
||||
- integration, email, telemetry, metering, follow-up, and survey auto-complete failures are logged inside the processor and do not fail the whole job
|
||||
|
||||
## Acceptance Criteria Review
|
||||
|
||||
### Pipeline Execution
|
||||
|
||||
Satisfied.
|
||||
|
||||
- New response create/update flows enqueue BullMQ jobs instead of calling an internal HTTP route.
|
||||
- The job payload contains `environmentId`, `surveyId`, `event`, and an authoritative response snapshot.
|
||||
- The response pipeline executes inside the BullMQ worker runtime.
|
||||
|
||||
### Feature Parity
|
||||
|
||||
Mostly satisfied for the legacy response pipeline behavior that existed in the old route.
|
||||
|
||||
The migrated BullMQ processor preserves:
|
||||
|
||||
- webhook delivery
|
||||
- integrations
|
||||
- response-finished emails
|
||||
- follow-up execution
|
||||
- survey auto-complete and audit logging
|
||||
- response-created billing metering
|
||||
- response-created telemetry
|
||||
|
||||
One important behavior change still exists today:
|
||||
|
||||
- webhook delivery failures delay the remaining side effects until the final BullMQ attempt
|
||||
|
||||
That is closer to the legacy route, because the pipeline eventually continues even if webhook delivery never succeeds. It is still not exact feature parity, though, because the legacy route continued immediately while the BullMQ worker waits until retries are exhausted before it degrades webhook failure into a logged condition.
|
||||
|
||||
### Architecture
|
||||
|
||||
Satisfied.
|
||||
|
||||
- Enqueueing lives in the app layer through `apps/web/app/lib/pipelines.ts`.
|
||||
- Execution lives in the worker path under `apps/web/modules/response-pipeline/lib`.
|
||||
- `@formbricks/jobs` stays responsible for queue/runtime concerns and typed job contracts.
|
||||
|
||||
### Cleanup
|
||||
|
||||
Satisfied.
|
||||
|
||||
The legacy internal route has been removed:
|
||||
|
||||
```text
|
||||
apps/web/app/api/(internal)/pipeline/route.ts
|
||||
```
|
||||
|
||||
The runtime path no longer depends on the old internal-route folder structure, and the remaining pipeline-only test mock under that deleted folder has been removed as part of the migration cleanup.
|
||||
|
||||
### Reliability
|
||||
|
||||
Satisfied at the current ticket scope.
|
||||
|
||||
BullMQ jobs use shared default retry behavior:
|
||||
|
||||
- `attempts: 3`
|
||||
- exponential backoff starting at `1000ms`
|
||||
|
||||
Failures are logged with structured metadata such as:
|
||||
|
||||
- `jobId`
|
||||
- `attempt`
|
||||
- `jobName`
|
||||
- `queueName`
|
||||
- `environmentId`
|
||||
- `surveyId`
|
||||
- `responseId`
|
||||
|
||||
Request handlers remain non-blocking:
|
||||
|
||||
- if Redis is unavailable
|
||||
- if queueing is disabled
|
||||
- if snapshot hydration fails
|
||||
- if enqueueing fails
|
||||
|
||||
the request still completes, and the failure is logged.
|
||||
|
||||
Worker startup is also non-blocking:
|
||||
|
||||
- Next.js boot does not await BullMQ readiness
|
||||
- startup failures are logged
|
||||
- the web app schedules a retry instead of requiring an immediate process restart
|
||||
|
||||
### Worker Integration
|
||||
|
||||
Satisfied.
|
||||
|
||||
The response pipeline is processed by the same BullMQ worker runtime started from Next.js instrumentation. No standalone worker service was introduced as part of this migration.
|
||||
|
||||
### Developer Experience
|
||||
|
||||
Satisfied.
|
||||
|
||||
The public app-level API for request handlers is intentionally small:
|
||||
|
||||
- `enqueueResponsePipelineEvents(...)`
|
||||
|
||||
This keeps queue names, Redis concerns, and BullMQ details out of response routes.
|
||||
|
||||
## Comparison With The Legacy Route
|
||||
|
||||
### Previous Implementation
|
||||
|
||||
The legacy internal route accepted a full response payload directly and then executed the entire pipeline synchronously inside the route handler.
|
||||
|
||||
Key characteristics of that model:
|
||||
|
||||
- request handlers performed an internal authenticated `fetch()` back into the same app
|
||||
- the route received the response payload directly instead of hydrating it from a queue-side snapshot
|
||||
- webhook failures were logged and did not block the rest of the pipeline
|
||||
- response-finished integrations, emails, follow-ups, and survey auto-complete ran in the same route execution
|
||||
- response-created metering was fire-and-forget while telemetry was awaited
|
||||
|
||||
### Current BullMQ Implementation
|
||||
|
||||
The current branch enqueues a typed snapshot-based BullMQ job and executes the pipeline inside the in-process worker registered from Next.js instrumentation.
|
||||
|
||||
Key characteristics of the current model:
|
||||
|
||||
- request handlers enqueue directly through `enqueueResponsePipelineEvents(...)`
|
||||
- handlers now pass the just-written `TResponse` snapshot when they already have it
|
||||
- callers that do not already have a response snapshot use an uncached pipeline-specific lookup
|
||||
- worker startup is non-blocking and retries after transient failures
|
||||
- webhook failures fail early attempts so BullMQ can retry them
|
||||
- on the final attempt, webhook failures are logged and the remaining side effects continue
|
||||
- response-created metering is awaited before the BullMQ job completes
|
||||
|
||||
### Net Result
|
||||
|
||||
Compared to the legacy route, the current branch is:
|
||||
|
||||
- architecturally stronger
|
||||
- safer to scale and operate
|
||||
- easier to observe through structured job logging
|
||||
- closer to legacy feature parity than the earlier BullMQ iterations on this branch
|
||||
|
||||
The main remaining semantic difference is timing:
|
||||
|
||||
- the legacy route continued past webhook failures immediately
|
||||
- the BullMQ worker now continues only after webhook retries are exhausted
|
||||
|
||||
That is an intentional trade-off in the current branch, not an accident.
|
||||
|
||||
## Current Queue Model
|
||||
|
||||
The queue remains intentionally small:
|
||||
|
||||
- queue name: `background-jobs`
|
||||
- prefix: `formbricks:jobs`
|
||||
- job names:
|
||||
- `system.test-log`
|
||||
- `response-pipeline.process`
|
||||
|
||||
The response pipeline is the first production workload on this queue.
|
||||
|
||||
## Local Development
|
||||
|
||||
Local development works end to end as long as Redis is available and the worker is enabled.
|
||||
|
||||
Required inputs:
|
||||
|
||||
- `REDIS_URL`
|
||||
- optionally `BULLMQ_WORKER_ENABLED`
|
||||
- optionally `BULLMQ_WORKER_COUNT`
|
||||
- optionally `BULLMQ_WORKER_CONCURRENCY`
|
||||
|
||||
Behavior:
|
||||
|
||||
- if `REDIS_URL` is missing, queueing is skipped
|
||||
- if `BULLMQ_WORKER_ENABLED=0`, the worker is not started, but request-side enqueueing can still stay enabled in deployments that point at a separate BullMQ worker
|
||||
- outside tests, the worker is enabled by default
|
||||
|
||||
This makes it possible to develop request flows without hard-failing when Redis is absent, while still supporting full local end-to-end verification when Redis is running.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
### Logging
|
||||
|
||||
The current implementation logs:
|
||||
|
||||
- worker startup failures
|
||||
- Redis connection failures
|
||||
- enqueue failures
|
||||
- job failures
|
||||
- webhook delivery failures
|
||||
- integration failures
|
||||
- email delivery failures
|
||||
- follow-up failures
|
||||
- survey auto-complete update failures
|
||||
- metering failures
|
||||
- telemetry failures
|
||||
|
||||
### Shutdown
|
||||
|
||||
The worker runtime registers `SIGTERM` and `SIGINT` handlers, closes workers and queue handles, and then closes Redis connections. This keeps shutdown behavior predictable inside the web process.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
The migration satisfies the ticket, but a few larger architectural limits remain by design.
|
||||
|
||||
### Dual-Write Boundary
|
||||
|
||||
Response writes happen in Postgres and background jobs are enqueued in Redis. Those are separate systems, so this remains a dual-write boundary.
|
||||
|
||||
This means Formbricks currently has:
|
||||
|
||||
- non-blocking enqueue semantics
|
||||
- at-least-once background execution
|
||||
- no transactional guarantee that the product write and Redis enqueue succeed together
|
||||
|
||||
That trade-off was accepted for this BullMQ phase.
|
||||
|
||||
### In-Process Workers
|
||||
|
||||
Workers run inside the Next.js app process.
|
||||
|
||||
That keeps self-hosting simple, but it also means:
|
||||
|
||||
- job capacity still shares resources with the web process
|
||||
- heavy background work is still Node.js-local
|
||||
- scaling job throughput also scales the app runtime
|
||||
|
||||
### Webhook-Gated Retries
|
||||
|
||||
Webhook delivery still happens before the rest of the `responseFinished` side effects.
|
||||
|
||||
That gives Formbricks job-level retries for webhook delivery, but it also means:
|
||||
|
||||
- `responseFinished` side effects do not run on the early retry attempts
|
||||
- the remaining side effects only continue after webhook retries are exhausted
|
||||
- this is closer to legacy behavior than failing forever, but it is still not immediate parity
|
||||
|
||||
This is the current behavior of the branch and should be evaluated explicitly if we want stricter feature parity with the legacy route.
|
||||
|
||||
### Logs-First Observability
|
||||
|
||||
The current system has strong structured logging, but it does not yet provide:
|
||||
|
||||
- queue dashboards
|
||||
- retry tooling
|
||||
- latency metrics
|
||||
- product-native workflow inspection
|
||||
|
||||
Those are future improvements, not blockers for the current migration.
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
Now that the response pipeline is on BullMQ, the most useful next steps are:
|
||||
|
||||
1. migrate additional low-risk async workloads behind the same producer/runtime boundary
|
||||
2. add queue metrics and worker health visibility beyond logs
|
||||
3. define explicit idempotency rules for side-effect-heavy jobs
|
||||
4. decide which future workloads should remain Node-local and which should eventually move to a different runtime
|
||||
|
||||
## Practical Conclusion
|
||||
|
||||
Formbricks now has:
|
||||
|
||||
- a production-capable BullMQ foundation
|
||||
- a real migrated workload
|
||||
- a clean separation between request-time enqueueing and background execution
|
||||
|
||||
The response pipeline migration should be considered complete for the current ticket scope.
|
||||
@@ -16,8 +16,31 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
|
||||
- A Formbricks instance running
|
||||
|
||||
### Account deletion reauthentication
|
||||
|
||||
For SSO-only users, Formbricks requires a fresh Google `auth_time` claim before deleting the account. Google only returns this claim when your OAuth app is published, verified, and has **Session age claims** enabled in Google Auth Platform.
|
||||
|
||||
To enable it, open Google Auth Platform, select your app project, go to **Settings**, and under **Advanced Settings** enable **Session age claims**. Then set:
|
||||
|
||||
```sh
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
```
|
||||
|
||||
If this Google setting and environment variable are not enabled together, Google login can still work, but SSO-only account deletion will fail closed.
|
||||
|
||||
Google does not support app-triggered Google Account reauthentication requests. If the returned `auth_time` is too old, the deletion flow is rejected and the user must complete Google sign-in from a fresh Google session before trying again.
|
||||
|
||||
<Warning>
|
||||
If you need to allow SSO-only users to delete their accounts without a fresh SSO reauthentication check, set
|
||||
`DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1`. This bypasses the deletion reauthentication marker for passwordless
|
||||
SSO accounts, so users can delete their account with email confirmation only. Keep it unset unless you
|
||||
accept this security trade-off.
|
||||
</Warning>
|
||||
|
||||
### How to connect your Formbricks instance to Google
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a GCP Project">
|
||||
- Navigate to the [GCP Console](https://console.cloud.google.com/).
|
||||
@@ -43,20 +66,25 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
Authorized JavaScript origins: {WEBAPP_URL}
|
||||
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Update Environment Variables in Docker">
|
||||
- To integrate the Google OAuth, you have two options: either update the environment variables in the docker-compose file or directly add them to the running container.
|
||||
|
||||
|
||||
- In your Docker setup directory, open the `.env` file, and add or update the following lines with the `Client ID` and `Client Secret` obtained from Google Cloud Platform:
|
||||
|
||||
|
||||
```sh
|
||||
GOOGLE_CLIENT_ID=your-client-id-here
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
# Optional: only when Google Auth Platform Session age claims are enabled.
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
# Optional: dangerous fallback that disables fresh SSO reauthentication for account deletion.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
```
|
||||
|
||||
|
||||
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
|
||||
|
||||
|
||||
```sh
|
||||
docker exec -it container_id /bin/bash
|
||||
export GOOGLE_CLIENT_ID=your-client-id-here
|
||||
@@ -77,3 +105,5 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
@@ -8,6 +8,8 @@ icon: "code"
|
||||
|
||||
These variables are present inside your machine's docker-compose file. Restart the docker containers if you change any variables for them to take effect.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
|
||||
@@ -32,6 +34,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| DISABLE_ACCOUNT_DELETION_SSO_REAUTH | Disables fresh SSO reauthentication for passwordless SSO account deletion if set to 1. Users can delete their account with email confirmation only. Keep unset unless you accept this security trade-off. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
@@ -53,6 +56,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
|
||||
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
||||
| GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED | Enables Google `auth_time` validation for SSO-only account deletion if set to 1. Only enable after Google Auth Platform Session age claims are enabled for the OAuth app. | optional | |
|
||||
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
|
||||
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
|
||||
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
|
||||
@@ -94,4 +98,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
|
||||
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
|
||||
|
||||
+31
-10
@@ -83,26 +83,47 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.10",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"schema-utils@3.3.0>ajv": "6.14.0",
|
||||
"axios": "1.13.5",
|
||||
"effect": "3.20.0",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.7",
|
||||
"@hono/node-server": "1.19.13",
|
||||
"@microsoft/api-extractor>minimatch": "10.2.4",
|
||||
"node-forge": ">=1.3.2",
|
||||
"@protobufjs/utf8": "1.1.1",
|
||||
"@react-email/preview-server>next": "16.2.6",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"@xmldom/xmldom": "0.9.10",
|
||||
"ajv@6": "6.14.0",
|
||||
"axios": "1.16.0",
|
||||
"brace-expansion@1": "1.1.14",
|
||||
"brace-expansion@2": "2.0.3",
|
||||
"brace-expansion@5": "5.0.6",
|
||||
"defu": "6.1.7",
|
||||
"dompurify": "3.4.2",
|
||||
"effect": "3.20.0",
|
||||
"fast-uri": "3.1.2",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.18",
|
||||
"ip-address": "10.1.1",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"node-forge": "1.4.0",
|
||||
"picomatch@2": "2.3.2",
|
||||
"picomatch@4": "4.0.4",
|
||||
"postcss": "8.5.14",
|
||||
"protobufjs@7": "7.5.8",
|
||||
"protobufjs@8": "8.2.0",
|
||||
"qs": "6.14.2",
|
||||
"rollup": "4.59.0",
|
||||
"socket.io-parser": "4.2.6",
|
||||
"tar": ">=7.5.11",
|
||||
"typeorm": ">=0.3.26",
|
||||
"undici": "7.24.0",
|
||||
"fast-xml-parser": "5.5.7",
|
||||
"uuid@11": "11.1.1",
|
||||
"vite@7": "7.3.3",
|
||||
"vite@8": "8.0.12",
|
||||
"yaml": "2.8.3",
|
||||
"diff": ">=8.0.3"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono - awaiting Prisma update | @microsoft/api-extractor>minimatch - awaiting api-extractor update | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @react-email/preview-server>next - awaiting react-email update | @tootallnate/once - awaiting sqlite3/node-gyp chain update | @xmldom/xmldom (CVE-2025-63067/63068) - awaiting @boxyhq/saml20 update | ajv@6 - awaiting @microsoft/tsdoc-config/eslint update | axios (CVE-2025-58754 et al.) - awaiting @boxyhq/saml-jackson update | brace-expansion@1/2/5 (CVE-2025-67313) - awaiting eslint/typeorm/typescript-eslint update | defu (CVE-2025-62629) - awaiting @prisma/config update | dompurify (CVE-2025-26791 et al.) - awaiting posthog-js/isomorphic-dompurify update | effect - awaiting Prisma update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser (CVE-2026-25896 et al.) - awaiting azure/core-xml update | flatted - awaiting eslint/flat-cache update | ip-address (CVE-2025-62629) - awaiting mongodb/socks update | lodash/lodash-es (CVE-2025-62616) - awaiting @boxyhq/saml-jackson/@trivago/prettier-plugin update | node-forge - awaiting @boxyhq/saml-jackson update | picomatch@2/4 (CVE-2025-60538/63394) - awaiting lint-staged/storybook update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | qs - awaiting googleapis/googleapis-common update | rollup - awaiting Vite patch adoption | socket.io-parser - awaiting react-email/socket.io update | tar - awaiting @boxyhq/saml-jackson/sqlite3 updates | typeorm - awaiting @boxyhq/saml-jackson update | undici - awaiting jsdom/vitest/isomorphic-dompurify updates | uuid@11 (CVE-2025-61475) - awaiting typeorm update | vite@7/8 (GHSA-v2wj-q39q-566r/p9ff-h696-f583) - awaiting workspace packages to update vite dependency | yaml (CVE-2025-63675) - awaiting lint-staged update | diff - awaiting upstream patch range adoption"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
Vendored
+2
@@ -93,8 +93,10 @@ describe("@formbricks/cache types/keys", () => {
|
||||
describe("CustomCacheNamespace type", () => {
|
||||
test("should include expected namespaces", () => {
|
||||
// Type test - this will fail at compile time if types don't match
|
||||
const accountDeletionNamespace: CustomCacheNamespace = "account_deletion";
|
||||
const analyticsNamespace: CustomCacheNamespace = "analytics";
|
||||
const billingNamespace: CustomCacheNamespace = "billing";
|
||||
expect(accountDeletionNamespace).toBe("account_deletion");
|
||||
expect(analyticsNamespace).toBe("analytics");
|
||||
expect(billingNamespace).toBe("billing");
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
|
||||
* Possible namespaces for custom cache keys
|
||||
* Add new namespaces here as they are introduced
|
||||
*/
|
||||
export type CustomCacheNamespace = "analytics" | "billing";
|
||||
export type CustomCacheNamespace = "account_deletion" | "analytics" | "billing";
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "Survey"
|
||||
ADD COLUMN "isAutoProgressingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -51,7 +51,7 @@
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.2",
|
||||
"bcryptjs": "3.0.3",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "13.0.2",
|
||||
"zod": "4.3.6",
|
||||
"zod-openapi": "5.4.6"
|
||||
},
|
||||
|
||||
@@ -395,7 +395,6 @@ model Survey {
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isAutoProgressingEnabled Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
pin String?
|
||||
displayPercentage Decimal?
|
||||
|
||||
@@ -138,7 +138,6 @@ const ZSurveyBase = z.object({
|
||||
isSingleResponsePerEmailEnabled: z.boolean().describe("Whether single response per email is enabled"),
|
||||
inlineTriggers: z.array(z.any()).nullable().describe("Inline triggers configuration"),
|
||||
isBackButtonHidden: z.boolean().describe("Whether the back button is hidden"),
|
||||
isAutoProgressingEnabled: z.boolean().describe("Whether auto-progress is enabled for eligible questions"),
|
||||
recaptcha: ZSurveyRecaptcha.describe("Google reCAPTCHA configuration"),
|
||||
metadata: ZSurveyMetadata.describe("Custom link metadata for social sharing"),
|
||||
displayPercentage: z.number().nullable().describe("The display percentage of the survey"),
|
||||
|
||||
@@ -79,5 +79,4 @@ export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
brandColor: { light: "#2B6CB0" },
|
||||
},
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
| "delay"
|
||||
| "projectOverwrites"
|
||||
| "isBackButtonHidden"
|
||||
| "isAutoProgressingEnabled"
|
||||
| "recaptcha"
|
||||
> & {
|
||||
languages: (SurveyLanguage & { language: Language })[];
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the AWS SDK S3Client
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(function MockS3Client(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user