Compare commits

...

22 Commits

Author SHA1 Message Date
Tiago Farto ce0a943cf4 fix(api): reject unreadable org-only survey keys 2026-05-08 15:18:36 +00:00
Tiago Farto 401276dae6 test(api): cover org-read v1 me shape 2026-05-08 15:12:33 +00:00
Tiago Farto 51583a422b Merge remote-tracking branch 'origin/main' into chore/readonly_org_fix 2026-05-08 14:51:52 +00:00
Tiago 69ead97965 fix: sso account deletion password check (#7930) 2026-05-08 12:07:58 +00:00
Tiago Farto ff20e7b074 fix(api): scope org-only v1 API key auth 2026-05-08 11:10:35 +00:00
Tiago Farto b92ea5f64a chore: read only org fix 2026-05-08 09:57:26 +00:00
Dhruwang Jariwala 7e2c439325 feat: add PostHog group analytics and feature events (#7914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
2026-05-07 07:49:27 +00:00
Javi Aguilar a2177eec96 fix: survey modal accessibility issues by using a focus trap (#7939) 2026-05-07 06:14:06 +00:00
Javi Aguilar 255c97854f fix: survey runtime accessibility for keyboard controls (#7927) 2026-05-06 10:21:42 +00:00
Matti Nannt d103499496 fix(security): strip sensitive survey and segment metadata from public client API (#7931)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-06 09:54:15 +00:00
Matti Nannt b863238f15 refactor: rename gethasNoOrganizations to getHasNoOrganizations (#7940)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 05:03:02 +00:00
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
184 changed files with 9234 additions and 940 deletions
+10
View File
@@ -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=
+28
View File
@@ -155,3 +155,31 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
+30
View File
@@ -0,0 +1,30 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
@@ -2,6 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -18,6 +18,7 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -242,7 +243,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -7,6 +7,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -51,6 +52,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,6 +10,7 @@ import {
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -80,6 +81,19 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -25,6 +27,14 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -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,14 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
@@ -18,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";
@@ -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" />
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -146,6 +147,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
@@ -153,7 +155,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId,
access: [
{
type: "organization",
@@ -161,7 +163,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId,
minPermission: "readWrite",
},
],
@@ -178,6 +180,18 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,6 +21,7 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -31,6 +32,40 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -51,6 +86,25 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -73,7 +127,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -87,9 +143,17 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
) : (
<LoadingSpinner />
)}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,10 +1,12 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -17,12 +19,9 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, survey);
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
return extractEmailBodyFragment(html.toString());
};
@@ -0,0 +1,11 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -42,18 +42,25 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: projectId,
},
{ organizationId, workspaceId: projectId }
);
return result;
});
@@ -43,14 +43,22 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
return result;
})
@@ -0,0 +1,160 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -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));
}
@@ -12,6 +12,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
workspaceId: "ws-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
@@ -23,15 +24,21 @@ describe("captureSurveyResponsePostHogEvent", () => {
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",
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
{
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
workspace_id: "ws-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
},
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
test("fires on every 100th response", async () => {
@@ -44,10 +51,20 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
test("fires on every 10th response up to 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
});
test("does NOT fire for non-milestone responses under 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
@@ -75,7 +92,8 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
}),
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
});
@@ -2,6 +2,7 @@ import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
workspaceId: string;
surveyId: string;
surveyType: string;
environmentId: string;
@@ -10,24 +11,36 @@ interface SurveyResponsePostHogEventParams {
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
* then every 100th (200, 300, 400, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
workspaceId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
const isFirst = responseCount === 1;
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
capturePostHogEvent(
organizationId,
"survey_response_received",
{
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
workspace_id: workspaceId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
},
{ organizationId, workspaceId }
);
};
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -307,9 +308,11 @@ export const POST = async (request: Request) => {
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
workspaceId,
surveyId,
surveyType: survey.type,
environmentId,
@@ -12,7 +12,7 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -87,10 +87,18 @@ export const GET = async (req: Request) => {
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
+124 -2
View File
@@ -1,9 +1,15 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth";
import { authenticateRequest, handleErrorResponse } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -171,7 +177,7 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
test("returns null when API key has no environment permissions", async () => {
test("returns null by default when API key has no environment permissions", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -192,4 +198,120 @@ describe("authenticateRequest", () => {
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
});
});
test("authenticates a read-only organization API key with no environment permissions", async () => {
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Read-only Organization API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+30 -8
View File
@@ -1,21 +1,30 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
type AuthenticateApiKeyOptions = {
allowOrganizationOnlyApiKey?: boolean;
};
// Get API key with permissions
export const authenticateApiKey = async (
apiKey: string,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return null;
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
return null;
}
// In the route handlers, we'll do more specific permission checks
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
@@ -33,6 +42,16 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
return authentication;
};
export const authenticateRequest = async (
request: NextRequest,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
return authenticateApiKey(apiKey, options);
};
export const handleErrorResponse = (error: any): Response => {
switch (error.message) {
case "NotAuthenticated":
@@ -40,6 +59,9 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
select: {
id: true,
welcomeCard: true,
name: true,
// name intentionally omitted — internal label not needed by the SDK
questions: true,
blocks: true,
variables: true,
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
segment: {
include: {
surveys: {
select: {
id: true,
},
},
select: {
id: true,
filters: true,
},
},
recontactDays: true,
@@ -147,10 +147,28 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
);
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
const transformedSurveys = environmentData.surveys.map((survey) => {
const minimalSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
const { segment: _segment, ...surveyWithoutSegment } = survey;
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
...surveyWithoutSegment,
segment: null,
});
return { ...transformed, segment: minimalSegment };
});
return {
environment: {
@@ -10,6 +10,11 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -22,6 +27,9 @@ vi.mock("@formbricks/database", () => ({
environment: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -163,6 +171,7 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
});
afterEach(() => {
@@ -329,11 +338,18 @@ describe("getEnvironmentState", () => {
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
environmentId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "test-org-id",
workspace_id: "test-project-id",
},
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
);
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -33,11 +33,25 @@ export const getEnvironmentState = async (
});
if (POSTHOG_KEY) {
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
const workspaceId = environment.project.id;
const project = await prisma.project.findUnique({
where: { id: workspaceId },
select: { organizationId: true },
});
const organizationId = project?.organizationId;
capturePostHogEvent(
environmentId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
}
}
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -95,10 +95,18 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -101,10 +101,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -109,10 +109,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -0,0 +1,199 @@
import { EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { buildApiKeyMeResponse } from "./api-key-response";
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage" = "read"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: EnvironmentType.development,
projectId: "project-id",
projectName: "Project Name",
});
describe("buildApiKeyMeResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [
{
environmentId: "env-1",
environmentType: EnvironmentType.development,
permissions: "read",
projectId: "project-id",
projectName: "Project Name",
},
{
environmentId: "env-2",
environmentType: EnvironmentType.development,
permissions: "write",
projectId: "project-id",
projectName: "Project Name",
},
],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns the legacy environment response for a single environment permission", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
const response = await buildApiKeyMeResponse(baseAuthentication);
expect(response).toBeNull();
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns null when the single permitted environment no longer exists", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response).toBeNull();
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
});
@@ -0,0 +1,49 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
environmentId: permission.environmentId,
environmentType: permission.environmentType,
permissions: permission.permission,
projectId: permission.projectId,
projectName: permission.projectName,
})),
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
});
export const buildApiKeyMeResponse = async (
authentication: TAuthenticationApiKey
): Promise<Response | null> => {
const environmentPermissionCount = authentication.environmentPermissions.length;
if (environmentPermissionCount !== 1) {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return Response.json(buildApiKeyMetadataResponse(authentication));
}
return null;
}
const permission = authentication.environmentPermissions[0];
const environment = await getEnvironment(permission.environmentId);
if (!environment) {
return null;
}
return Response.json({
id: environment.id,
type: environment.type,
createdAt: environment.createdAt,
updatedAt: environment.updatedAt,
appSetupCompleted: environment.appSetupCompleted,
project: {
id: permission.projectId,
name: permission.projectName,
},
});
};
@@ -0,0 +1,128 @@
import { EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { authenticateApiKey } from "@/app/api/v1/auth";
import { getEnvironment } from "@/lib/environment/service";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { GET } from "./route";
const { mockHeaders, mockPrisma } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockPrisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("next/headers", () => ({
headers: mockHeaders,
}));
vi.mock("@formbricks/database", () => ({
prisma: mockPrisma,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateApiKey: vi.fn(),
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
describe("GET /api/v1/management/me", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeaders.mockResolvedValue(new Headers({ "x-api-key": "read-only-org-api-key" }));
});
test("accepts a read-only organization API key without environment permissions", async () => {
vi.mocked(authenticateApiKey).mockResolvedValue({
type: "apiKey",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [],
});
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
environmentPermissions: [],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(authenticateApiKey).toHaveBeenCalledWith("read-only-org-api-key", {
allowOrganizationOnlyApiKey: true,
});
expect(applyRateLimit).toHaveBeenCalledWith(expect.any(Object), "api-key-id");
});
test("preserves the legacy environment response for single-environment API keys", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(authenticateApiKey).mockResolvedValue({
type: "apiKey",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [
{
environmentId: "env-id",
environmentType: EnvironmentType.development,
permission: "read",
projectId: "project-id",
projectName: "Project Name",
},
],
});
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
});
});
+9 -136
View File
@@ -1,104 +1,13 @@
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { authenticateApiKey } from "@/app/api/v1/auth";
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
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";
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
const apiKeySelect = {
id: true,
organizationId: true,
lastUsedAt: true,
apiKeyEnvironments: {
select: {
environment: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
},
},
hashedKey: true,
};
type ApiKeyData = {
id: string;
hashedKey: string;
organizationId: string;
lastUsedAt: Date | null;
apiKeyEnvironments: Array<{
permission: string;
environment: {
id: string;
type: string;
createdAt: Date;
updatedAt: Date;
projectId: string;
appSetupCompleted: boolean;
project: {
id: string;
name: string;
};
};
}>;
};
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const v2Parsed = parseApiKeyV2(apiKey);
if (v2Parsed) {
return validateV2ApiKey(v2Parsed);
}
return validateLegacyApiKey(apiKey);
};
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
// Step 1: Fast SHA-256 lookup by indexed lookupHash
const lookupHash = hashSha256(v2Parsed.secret);
const apiKeyData = await prisma.apiKey.findUnique({
where: { lookupHash },
select: apiKeySelect,
});
// Step 2: Security verification with bcrypt
// Always perform bcrypt verification to prevent timing attacks
// Use a control hash when API key doesn't exist to maintain constant timing
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
if (!apiKeyData || !isValid) return null;
return apiKeyData;
};
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const hashedKey = hashSha256(apiKey);
const result = await prisma.apiKey.findFirst({
where: { hashedKey },
select: apiKeySelect,
});
return result;
};
const checkRateLimit = async (userId: string) => {
try {
await applyRateLimit(rateLimitConfigs.api.v1, userId);
@@ -110,59 +19,23 @@ const checkRateLimit = async (userId: string) => {
return null;
};
const updateApiKeyUsage = async (apiKeyId: string) => {
await prisma.apiKey.update({
where: { id: apiKeyId },
data: { lastUsedAt: new Date() },
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyEnvironments[0].environment;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
project: {
id: env.projectId,
name: env.project.name,
},
});
};
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
return (
apiKeyData.apiKeyEnvironments.length === 1 &&
ALLOWED_PERMISSIONS.includes(
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
)
);
};
const handleApiKeyAuthentication = async (apiKey: string) => {
const apiKeyData = await validateApiKey(apiKey);
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
if (!apiKeyData) {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
// Fire-and-forget: update lastUsedAt in the background without blocking the response
updateApiKeyUsage(apiKeyData.id).catch((error) => {
console.error("Failed to update API key usage:", error);
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
if (rateLimitError) return rateLimitError;
if (!isValidApiKeyEnvironment(apiKeyData)) {
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
if (!apiKeyMeResponse) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
return apiKeyMeResponse;
};
const handleSessionAuthentication = async () => {
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { getReadableEnvironmentIds } from "./access";
vi.mock("@/lib/environment/organization", () => ({
getEnvironmentIdsByOrganizationId: vi.fn(),
}));
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: "development",
projectId: `project-${environmentId}`,
projectName: `Project ${environmentId}`,
});
describe("getReadableEnvironmentIds", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns all organization environments when API key has organization read access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual(["env-1", "env-2"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual([]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns all organization environments when API key has organization write access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: false,
write: true,
},
},
});
expect(result).toEqual(["env-1"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
const result = await getReadableEnvironmentIds({
...baseAuthentication,
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
environmentPermission("env-3", "manage"),
environmentPermission("env-1", "read"),
],
});
expect(result).toEqual(["env-1", "env-2", "env-3"]);
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
test("returns null when the API key has no readable access", async () => {
const result = await getReadableEnvironmentIds(baseAuthentication);
expect(result).toBeNull();
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const getReadableEnvironmentIds = async (
authentication: TAuthenticationApiKey
): Promise<string[] | null> => {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
}
const environmentIds = authentication.environmentPermissions
.filter((permission) =>
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
)
.map((permission) => permission.environmentId);
const readableEnvironmentIds = Array.from(new Set(environmentIds));
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
};
@@ -0,0 +1,149 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { getSurveys } from "./lib/surveys";
import { GET } from "./route";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
}));
vi.mock("@/lib/environment/organization", () => ({
getEnvironmentIdsByOrganizationId: vi.fn(),
}));
vi.mock("./lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
describe("GET /api/v1/management/surveys", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
vi.mocked(getSurveys).mockResolvedValue([]);
});
test("accepts a read-only organization API key without environment permissions", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
expect(getSurveys).toHaveBeenCalledWith(["env-1", "env-2"], undefined, undefined);
});
test("returns an empty survey list for an organization-read API key when the organization has no environments", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
expect(getSurveys).toHaveBeenCalledWith([], undefined, undefined);
});
test("rejects an organization-only API key without readable organization access", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "no-access-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({ code: "unauthorized" });
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
expect(getSurveys).not.toHaveBeenCalled();
});
test("uses explicit readable environment permissions without organization read access", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "read",
environment: {
id: "env-1",
type: "development",
projectId: "project-1",
project: { id: "project-1", name: "Project 1" },
},
},
],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys?limit=10&offset=5", {
headers: { "x-api-key": "environment-read-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
expect(getSurveys).toHaveBeenCalledWith(["env-1"], 10, 5);
});
});
@@ -14,9 +14,11 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getReadableEnvironmentIds } from "./lib/access";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
allowOrganizationOnlyApiKey: true,
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
@@ -27,9 +29,10 @@ export const GET = withV1ApiWrapper({
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentIds = await getReadableEnvironmentIds(authentication);
if (!environmentIds) {
return { response: responses.unauthorizedResponse() };
}
const surveys = await getSurveys(environmentIds, limit, offset);
@@ -587,6 +587,56 @@ describe("withV1ApiWrapper", () => {
});
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
test("does not allow organization-only API keys by default", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const response = await wrapped(req, undefined);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
});
test("allows organization-only API keys when the route opts in", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
const response = await wrapped(req, undefined);
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
});
});
describe("buildAuditLogBaseObject", () => {
+18 -5
View File
@@ -46,6 +46,11 @@ export interface TWithV1ApiWrapperParams<
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
/**
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
* support organization-only API keys.
*/
allowOrganizationOnlyApiKey?: boolean;
}
enum ApiV1RouteTypeEnum {
@@ -142,16 +147,17 @@ const setupAuditLog = (
*/
const handleAuthentication = async (
authenticationMethod: AuthenticationMethod,
req: NextRequest
req: NextRequest,
allowOrganizationOnlyApiKey = false
): Promise<TApiV1Authentication> => {
switch (authenticationMethod) {
case AuthenticationMethod.ApiKey:
return await authenticateRequest(req);
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
case AuthenticationMethod.Session:
return await getServerSession(authOptions);
case AuthenticationMethod.Both: {
const session = await getServerSession(authOptions);
return session ?? (await authenticateRequest(req));
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
}
case AuthenticationMethod.None:
return null;
@@ -251,7 +257,14 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
const {
handler,
action,
targetType,
customRateLimitConfig,
unauthenticatedResponse,
allowOrganizationOnlyApiKey,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -270,7 +283,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
}
// === Authentication ===
const authentication = await handleAuthentication(authenticationMethod, req);
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
@@ -0,0 +1,57 @@
"use client";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
interface PostHogGroupIdentifyProps {
organizationId: string;
organizationName: string;
workspaceId: string;
workspaceName: string;
}
export const PostHogGroupIdentify = ({
organizationId,
organizationName,
workspaceId,
workspaceName,
}: PostHogGroupIdentifyProps) => {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
const applyGroups = () => {
posthog.group("organization", organizationId, { name: organizationName });
posthog.group("workspace", workspaceId, { name: workspaceName });
};
if (posthog.__loaded) {
applyGroups();
return;
}
// PostHogIdentify (in app layout) initialises posthog from a sibling
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
const intervalId = setInterval(() => {
if (cancelledRef.current) return;
if (posthog.__loaded) {
applyGroups();
clearInterval(intervalId);
}
}, 50);
const timeoutId = setTimeout(() => {
cancelledRef.current = true;
clearInterval(intervalId);
}, 5000);
return () => {
cancelledRef.current = true;
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [organizationId, organizationName, workspaceId, workspaceName]);
return null;
};
@@ -4,10 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const hasNoOrganizations = await getHasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
@@ -50,10 +50,17 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
},
{ organizationId: newOrganization.id }
);
return newOrganization;
})
+28
View File
@@ -0,0 +1,28 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Page = async () => {
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
getServerSession(authOptions),
getIsFreshInstance(),
getHasNoOrganizations(),
]);
if (isFreshInstance) {
return redirect("/setup/intro");
}
if (hasNoOrganizations) {
if (session) {
return redirect("/setup/organization/create");
}
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
}
return redirect("/");
};
export default Page;
+2 -1
View File
@@ -1192,6 +1192,7 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1481,7 +1482,7 @@ checksums:
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
+151 -2
View File
@@ -1,12 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
createActionClass,
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
updateActionClass,
} from "./service";
vi.mock("@formbricks/database", () => ({
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
+2
View File
@@ -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);
+4
View File
@@ -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,
@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getEnvironmentIdsByOrganizationId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getEnvironmentIdsByOrganizationId", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("returns environment IDs for all projects in an organization", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual(["env-1", "env-2"]);
expect(prisma.environment.findMany).toHaveBeenCalledWith({
where: {
project: {
organizationId: "clh6pzwx90000e9ogjr0mf7so",
},
},
select: {
id: true,
},
});
});
test("returns an empty list when the organization has no environments", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual([]);
});
test("throws DatabaseError for known Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
DatabaseError
);
});
test("rethrows unknown errors", async () => {
const error = new Error("boom");
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
});
});
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getEnvironmentIdsByOrganizationId = reactCache(
async (organizationId: string): Promise<string[]> => {
validateInputs([organizationId, ZId]);
try {
const environments = await prisma.environment.findMany({
where: {
project: {
organizationId,
},
},
select: {
id: true,
},
});
return environments.map((environment) => environment.id);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
+1 -1
View File
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
});
// Function to check if there are any organizations in the database
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
try {
const organizationCount = await prisma.organization.count();
return organizationCount === 0;
+121
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createAccountDeletionSsoReauthIntent,
createEmailChangeToken,
createEmailToken,
createInviteToken,
@@ -10,6 +11,7 @@ import {
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyAccountDeletionSsoReauthIntent,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
@@ -1082,5 +1084,124 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
describe("account deletion SSO reauthentication intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.returnToUrl,
TEST_ENCRYPTION_KEY
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: "sso_recovery",
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects account deletion reauth intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects expired account deletion reauth intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
{
id: accountDeletionIntent.id,
userId: accountDeletionIntent.userId,
email: accountDeletionIntent.email,
provider: accountDeletionIntent.provider,
providerAccountId: accountDeletionIntent.providerAccountId,
purpose: accountDeletionIntent.purpose,
returnToUrl: accountDeletionIntent.returnToUrl,
},
TEST_NEXTAUTH_SECRET
);
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
});
});
});
});
+85
View File
@@ -35,6 +35,16 @@ type TSsoRelinkIntentPayload = {
userId: string;
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
@@ -262,6 +272,10 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
@@ -323,6 +337,77 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload =>
};
};
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 verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
+81 -3
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { capturePostHogEvent } from "./capture";
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
const mocks = vi.hoisted(() => ({
capture: vi.fn(),
groupIdentify: vi.fn(),
loggerWarn: vi.fn(),
}));
@@ -13,7 +14,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("./server", () => ({
posthogServerClient: { capture: mocks.capture },
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
}));
describe("capturePostHogEvent", () => {
@@ -32,6 +33,7 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
@@ -45,6 +47,41 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
test("includes organization and workspace groups when provided", () => {
capturePostHogEvent(
"user123",
"test_event",
{ key: "value" },
{ organizationId: "org_1", workspaceId: "ws_1" }
);
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
key: "value",
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1", workspace: "ws_1" },
});
});
test("includes only organization group when workspaceId missing", () => {
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1" },
});
});
@@ -61,6 +98,44 @@ describe("capturePostHogEvent", () => {
});
});
describe("groupIdentifyPostHog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("calls posthog groupIdentify with correct params", () => {
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "org_1",
properties: { name: "Acme" },
});
});
test("identifies workspace group", () => {
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "workspace",
groupKey: "ws_1",
properties: { name: "Marketing" },
});
});
test("does not throw when groupIdentify throws", () => {
mocks.groupIdentify.mockImplementation(() => {
throw new Error("Network error");
});
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
"Failed to identify PostHog group"
);
});
});
describe("capturePostHogEvent with null client", () => {
test("no-ops when posthogServerClient is null", async () => {
vi.clearAllMocks();
@@ -74,11 +149,14 @@ describe("capturePostHogEvent with null client", () => {
posthogServerClient: null,
}));
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
await import("./capture");
captureWithNullClient("user123", "test_event", { key: "value" });
identifyWithNullClient("organization", "org_1");
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.groupIdentify).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
});
+36 -1
View File
@@ -4,10 +4,24 @@ import { posthogServerClient } from "./server";
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
export type PostHogGroupContext = {
organizationId?: string;
workspaceId?: string;
};
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
if (!context) return undefined;
const groups: Record<string, string> = {};
if (context.organizationId) groups.organization = context.organizationId;
if (context.workspaceId) groups.workspace = context.workspaceId;
return Object.keys(groups).length > 0 ? groups : undefined;
};
export function capturePostHogEvent(
distinctId: string,
eventName: string,
properties?: PostHogEventProperties
properties?: PostHogEventProperties,
groupContext?: PostHogGroupContext
): void {
if (!posthogServerClient) return;
@@ -20,8 +34,29 @@ export function capturePostHogEvent(
$lib: "posthog-node",
source: "server",
},
groups: buildGroups(groupContext),
});
} catch (error) {
logger.warn({ error, eventName }, "Failed to capture PostHog event");
}
}
type PostHogGroupType = "organization" | "workspace";
export function groupIdentifyPostHog(
groupType: PostHogGroupType,
groupKey: string,
properties?: Record<string, string | number | boolean | null | undefined>
): void {
if (!posthogServerClient) return;
try {
posthogServerClient.groupIdentify({
groupType,
groupKey,
properties,
});
} catch (error) {
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import "server-only";
export { capturePostHogEvent } from "./capture";
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
export type { PostHogGroupContext } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
+15
View File
@@ -0,0 +1,15 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
/**
* Adapts a full management-side `TSurvey` into the minimal
* `TJsEnvironmentStateSurvey` shape that the SDK widget / shared SDK utilities
* expect. Only the segment shape needs reshaping the rest of `TSurvey` is a
* structural superset of the SDK survey type.
*/
export const toJsEnvironmentStateSurvey = (survey: TSurvey): TJsEnvironmentStateSurvey => {
return {
...survey,
segment: survey.segment ? { id: survey.segment.id, hasFilters: survey.segment.filters.length > 0 } : null,
} as unknown as TJsEnvironmentStateSurvey;
};
+97
View File
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { getUserAuthenticationData, verifyUserPassword } from "./password";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPassword = vi.mocked(verifyPassword);
describe("user password helpers", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("returns authentication data for an existing user", async () => {
const user = {
email: "user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
};
mockPrismaUserFindUnique.mockResolvedValue(user as any);
await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: {
id: "user-with-password",
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
});
test("throws when authentication data is missing", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
});
test("verifies a password against the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(true);
await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);
expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
});
test("returns false when the password does not match the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(false);
await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);
expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
});
test("rejects password verification for users without a password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "sso-user@example.com",
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
} as any);
await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);
expect(mockVerifyPassword).not.toHaveBeenCalled();
});
});
+7 -3
View File
@@ -5,15 +5,19 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
const getUserAuthenticationData = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
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,
},
});
@@ -28,7 +32,7 @@ const getUserAuthenticationData = reactCache(
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (user.identityProvider !== "email" || !user.password) {
if (!user.password) {
throw new InvalidInputError("Password is not set for this user");
}
@@ -12,6 +12,7 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UniqueConstraintError,
UnknownError,
ValidationError,
isExpectedError,
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
@@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new UniqueConstraintError("Action with name foo already exists")
);
expect(result?.serverError).toBe("Action with name foo already exists");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
"link_expired_heading": "Dein Link ist abgelaufen."
},
"common": {
"accepted": "Akzeptiert",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
"image": "Bild",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
"completed_heading": "Abgeschlossen",
"create_your_own": "Erstelle deine eigene",
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
"just_curious": "Einfach neugierig?",
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
"paused": "Diese Umfrage ist vorübergehend pausiert.",
"paused_heading": "Pausiert",
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
"link_expired_description": "The link you used is no longer valid.",
"link_expired_heading": "Your link is expired."
},
"common": {
"accepted": "Accepted",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Hide Question settings",
"hostname": "Hostname",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
"completed": "This survey is closed.",
"completed_heading": "Completed",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below.",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This survey is temporarily paused.",
"paused_heading": "Paused",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Tu enlace ha caducado.",
"link_expired_description": "El enlace que has utilizado ya no es válido."
"link_expired_description": "El enlace que has utilizado ya no es válido.",
"link_expired_heading": "Tu enlace ha caducado."
},
"common": {
"accepted": "Aceptado",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Ocultar ajustes de la pregunta",
"hostname": "Nombre de host",
"if_you_need_more_please": "Si necesitas más, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
"ignore_global_waiting_time": "Ignorar periodo de espera",
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
"image": "Imagen",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
"completed": "Esta encuesta está cerrada.",
"completed_heading": "Completado",
"create_your_own": "Crea tu propia encuesta de código abierto",
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
"just_curious": "¿Solo tienes curiosidad?",
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
"paused": "Esta encuesta está temporalmente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
"question_preview": "Vista previa de la pregunta",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
"link_expired_heading": "Votre lien est expiré."
},
"common": {
"accepted": "Accepté",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Masquer les paramètres de la question",
"hostname": "Nom d'hôte",
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
"image": "Image",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
"completed": "Cette enquête gratuite et open-source a été fermée.",
"completed_heading": "Terminé",
"create_your_own": "Créez le vôtre",
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
"just_curious": "Juste curieux ?",
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
"paused_heading": "En pause",
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "A hivatkozása lejárt.",
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
"link_expired_heading": "A hivatkozása lejárt."
},
"common": {
"accepted": "Elfogadva",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Kérdésbeállítások elrejtése",
"hostname": "Gépnév",
"if_you_need_more_please": "Ha többre van szüksége, akkor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ el nem küldenek.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
"image": "Kép",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
"completed": "Ez a kérdőív le van zárva.",
"completed_heading": "Befejezve",
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
"just_curious": "Csak kíváncsi?",
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
"paused_heading": "Szüneteltetve",
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
"question_preview": "Kérdés előnézete",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "リンクの有効期限が切れています。",
"link_expired_description": "使用したリンクはすでに無効です。"
"link_expired_description": "使用したリンクはすでに無効です。",
"link_expired_heading": "リンクの有効期限が切れています。"
},
"common": {
"accepted": "承認済み",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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桁のコードを入力してください。",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "質問設定を非表示",
"hostname": "ホスト名",
"if_you_need_more_please": "さらに必要な場合は、",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
"ignore_global_waiting_time": "クールダウン期間を無視",
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
"image": "画像",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
"completed": "このフォームはクローズしました。",
"completed_heading": "完了",
"create_your_own": "独自のオープンソースフォームを作成",
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
"just_curious": "ただ興味があるだけですか?",
"link_invalid": "このフォームは招待によってのみ回答できます。",
"paused": "このフォームは一時的に一時停止されています。",
"paused_heading": "一時停止",
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
"preview_survey_questions": "フォームの質問をプレビュー。",
"question_preview": "質問プレビュー",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Uw link is verlopen.",
"link_expired_description": "De link die u gebruikte is niet meer geldig."
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
"link_expired_heading": "Uw link is verlopen."
},
"common": {
"accepted": "Geaccepteerd",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Vraaginstellingen verbergen",
"hostname": "Hostnaam",
"if_you_need_more_please": "Als je meer nodig hebt,",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
"ignore_global_waiting_time": "Afkoelperiode negeren",
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
"image": "Afbeelding",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
"completed": "Deze enquête is gesloten.",
"completed_heading": "Voltooid",
"create_your_own": "Creëer uw eigen open source-enquête",
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
"just_curious": "Gewoon nieuwsgierig?",
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
"paused": "Deze enquête is tijdelijk onderbroken.",
"paused_heading": "Gepauzeerd",
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
"preview_survey_questions": "Bekijk enquêtevragen.",
"question_preview": "Vraagvoorbeeld",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
"link_expired_description": "O link que você usou não é mais válido.",
"link_expired_heading": "Seu link está expirado."
},
"common": {
"accepted": "Aceito",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Ocultar configurações da pergunta",
"hostname": "nome do host",
"if_you_need_more_please": "Se você precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
"image": "imagem",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio",
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
"just_curious": "Só curioso?",
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
"link_expired_description": "O link que utilizou já não é válido.",
"link_expired_heading": "O seu link expirou."
},
"common": {
"accepted": "Aceite",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Ocultar definições da pergunta",
"hostname": "Nome do host",
"if_you_need_more_please": "Se precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
"image": "Imagem",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito está encerrado.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito está temporariamente suspenso.",
"paused_heading": "Em pausa",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Link-ul dumneavoastră a expirat.",
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
"link_expired_heading": "Link-ul dumneavoastră a expirat."
},
"common": {
"accepted": "Acceptat",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Ascunde setările întrebării",
"hostname": "Nume gazdă",
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
"ignore_global_waiting_time": "Ignoră perioada de răcire",
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
"image": "Imagine",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
"completed": "Acest chestionar este închis.",
"completed_heading": "Completat",
"create_your_own": "Creează-ți propriul chestionar open-source",
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
"just_curious": "Doar curios?",
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
"paused": "Acest sondaj este temporar întrerupt.",
"paused_heading": "Pauză",
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
"preview_survey_questions": "Previzualizare întrebări chestionar",
"question_preview": "Previzualizare Întrebare",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Ваша ссылка истекла.",
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
"link_expired_heading": "Ваша ссылка истекла."
},
"common": {
"accepted": "Принято",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Скрыть настройки вопроса",
"hostname": "Имя хоста",
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
"if_you_really_want_that_answer_ask_until_you_get_it": оказывать каждый раз при срабатывании, пока не будет получен ответ.",
"if_you_really_want_that_answer_ask_until_you_get_it": родолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
"ignore_global_waiting_time": "Игнорировать период ожидания",
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
"image": "Изображение",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
"completed": "Этот опрос закрыт.",
"completed_heading": "Завершено",
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
"just_curious": "Просто интересно?",
"link_invalid": "Пройти этот опрос можно только по приглашению.",
"paused": "Этот опрос временно приостановлен.",
"paused_heading": "Приостановлено",
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
"preview_survey_questions": "Просмотреть вопросы опроса.",
"question_preview": "Предпросмотр вопроса",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Din länk har gått ut.",
"link_expired_description": "Länken du använde är inte längre giltig."
"link_expired_description": "Länken du använde är inte längre giltig.",
"link_expired_heading": "Din länk har gått ut."
},
"common": {
"accepted": "Accepterad",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Dölj frågeinställningar",
"hostname": "Värdnamn",
"if_you_need_more_please": "Om du behöver mer, vänligen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
"ignore_global_waiting_time": "Ignorera väntetid",
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
"image": "Bild",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
"completed": "Denna enkät är stängd.",
"completed_heading": "Slutförd",
"create_your_own": "Skapa din egen öppenkällkodsenkät",
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
"just_curious": "Bara nyfiken?",
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
"paused": "Denna enkät är tillfälligt pausad.",
"paused_heading": "Pausad",
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
"question_preview": "Frågeförhandsgranskning",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Bağlantınızın süresi doldu.",
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil."
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil.",
"link_expired_heading": "Bağlantınızın süresi doldu."
},
"common": {
"accepted": "Kabul Edildi",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Hesabımı Sil",
"confirm_your_current_password_to_get_started": "Başlamak için mevcut şifrenizi onaylayın.",
"delete_account": "Hesabı Sil",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı devre dışı bırak",
"disable_two_factor_authentication_description": "2FA'yı devre dışı bırakmanız gerekiyorsa, mümkün olan en kısa sürede yeniden etkinleştirmenizi öneririz.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Her yedek kod, kimlik doğrulayıcınız olmadan erişim sağlamak için tam olarak bir kez kullanılabilir.",
"email_change_initiated": "Email değişikliği talebiniz başlatıldı.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamanızdaki kodu aşağıya girin.",
"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": "Erişim kaybedildi",
"or_enter_the_following_code_manually": "Veya aşağıdaki kodu manuel olarak girin:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sizsiniz, bu nedenle <b>onlar da silinecektir.</b>",
@@ -1249,6 +1253,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedek kodları güvenli bir yere kaydedin.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulama uygulamanızla tarayın.",
"security_description": "Şifrenizi ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönetin.",
"sso_reauthentication_failed": "SSO yeniden kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_reauthentication_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa hesabınız otomatik olarak silinir.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifrenizin çalınması durumuna karşı hesabınıza ekstra bir güvenlik katmanı ekleyin.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulama uygulamanızdaki altı haneli kodu girin.",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "Soru ayarlarını gizle",
"hostname": "Ana bilgisayar adı",
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
"image": "Görsel",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Email'i gelen kutunuzda görmüyorsanız lütfen spam klasörünüzü de kontrol edin.",
"completed": "Bu survey kapatılmıştır.",
"completed_heading": "Tamamlandı",
"create_your_own": "Kendi açık kaynak survey'inizi oluşturun",
"enter_pin": "Bu survey korumalıdır. Aşağıya PIN girin.",
"just_curious": "Sadece merak mı ediyorsunuz?",
"link_invalid": "Bu survey yalnızca davet ile katılıma açıktır.",
"paused": "Bu survey geçici olarak duraklatılmıştır.",
"paused_heading": "Duraklatıldı",
"please_try_again_with_the_original_link": "Lütfen orijinal bağlantıyla tekrar deneyin",
"preview_survey_questions": "Survey sorularını önizleyin.",
"question_preview": "Soru Önizlemesi",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您的 链接 已过期。",
"link_expired_description": "您 使用 的 链接 已失效。"
"link_expired_description": "您 使用 的 链接 已失效。",
"link_expired_heading": "您的 链接 已过期。"
},
"common": {
"accepted": "已接受",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "隐藏问题设置",
"hostname": "主 机 名",
"if_you_need_more_please": "如果您需要更多,请",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
"ignore_global_waiting_time": "忽略冷却期",
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
"image": "图片",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
"completed": "此 调查 关闭",
"completed_heading": "完成",
"create_your_own": "创建 你 的 开源 调查",
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
"just_curious": "只是好奇 ",
"link_invalid": "此调查只能通过邀请参加。",
"paused": "此调查暂时暂停。",
"paused_heading": "暂停",
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
"preview_survey_questions": "预览 问卷调查 问题。",
"question_preview": "问题 预览",
+10 -2
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
"link_expired_description": "您 使用 的 連結 已無效。",
"link_expired_heading": "您 的 連結 已過期。"
},
"common": {
"accepted": "已接受",
@@ -1233,12 +1234,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>",
@@ -1249,6 +1253,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": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -1553,7 +1559,7 @@
"hide_question_settings": "隱藏問題設定",
"hostname": "主機名稱",
"if_you_need_more_please": "如果您需要更多,請",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
"ignore_global_waiting_time": "忽略冷卻期",
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
"image": "圖片",
@@ -2428,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
"completed": "此免費且開源的問卷已關閉。",
"completed_heading": "已完成",
"create_your_own": "建立您自己的",
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
"just_curious": "只是好奇?",
"link_invalid": "此問卷只能透過邀請填寫。",
"paused": "此免費且開源的問卷已暫時暫停。",
"paused_heading": "已暫停",
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
@@ -2,83 +2,70 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { 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";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
"Password and email confirmation are required to delete your account.";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().min(1).max(255),
confirmationEmail: z.string().trim().pipe(ZUserEmail),
password: z.string().max(128).optional(),
})
.strict();
const parseDeleteUserConfirmation = (input: unknown) => {
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return password;
};
const 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 deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
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;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
const { confirmationEmail, password } = parsedInput;
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
});
ctx.auditLoggingCtx.oldObject = oldUser;
if (isPasswordBackedAccount) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
await deleteUser(ctx.user.id);
capturePostHogEvent(ctx.user.id, "delete_account");
return { success: true };
} catch (error) {
@@ -1 +0,0 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -1,5 +1,6 @@
"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";
@@ -7,14 +8,22 @@ 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 { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
user: TUser;
@@ -23,18 +32,18 @@ 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 isPasswordBackedAccount = user.identityProvider === "email";
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -48,8 +57,59 @@ export const DeleteAccountModal = ({
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
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 {
@@ -59,7 +119,7 @@ export const DeleteAccountModal = ({
setDeleting(true);
const result = await deleteUserAction(
isPasswordBackedAccount
requiresPasswordConfirmation
? {
confirmationEmail: inputValue,
password,
@@ -71,12 +131,14 @@ export const DeleteAccountModal = ({
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = fallbackErrorMessage;
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
await startSsoReauthentication();
return;
} else if (result) {
errorMessage = getFormattedErrorMessage(result);
errorMessage =
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
}
logger.error({ errorMessage }, "Account deletion action failed");
@@ -93,9 +155,9 @@ 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) {
logger.error({ error }, "Account deletion failed");
@@ -161,7 +223,12 @@ export const DeleteAccountModal = ({
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{isPasswordBackedAccount && (
{!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")}
+11
View File
@@ -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,12 @@
import { describe, expect, test } from "vitest";
import { requiresPasswordConfirmationForAccountDeletion } from "./account-deletion-auth";
describe("account deletion auth requirements", () => {
test("requires password confirmation for password-backed users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "email" })).toBe(true);
});
test("does not require password confirmation for SSO-only users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "google" })).toBe(false);
});
});
@@ -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,905 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
consumeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
startAccountDeletionSsoReauthentication,
validateAccountDeletionSsoReauthenticationCallback,
} from "./account-deletion-sso-reauth";
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
findUnique: vi.fn(),
},
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
getRedisClient: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@/lib/jwt", () => ({
createAccountDeletionSsoReauthIntent: vi.fn(),
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/lib/user/password", () => ({
getUserAuthenticationData: vi.fn(),
}));
const mockCache = vi.mocked(cache);
const mockPrismaAccountFindUnique = vi.mocked(prisma.account.findUnique);
const mockPrismaUserFindFirst = vi.mocked(prisma.user.findFirst);
const mockCreateAccountDeletionSsoReauthIntent = vi.mocked(createAccountDeletionSsoReauthIntent);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockGetUserAuthenticationData = vi.mocked(getUserAuthenticationData);
const cacheError = { code: ErrorCode.Unknown };
const intent = {
id: "intent-id",
email: "sso-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
userId: "user-id",
};
const storedIntent = {
id: intent.id,
provider: intent.provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
};
const samlIntent = {
...intent,
provider: "saml",
providerAccountId: "saml-account-id",
};
const storedSamlIntent = {
id: samlIntent.id,
provider: samlIntent.provider,
providerAccountId: samlIntent.providerAccountId,
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO reauthentication", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
mockCache.getRedisClient.mockResolvedValue(null);
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockPrismaUserFindFirst.mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: "SSO-USER@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), storedIntent, 10 * 60 * 1000);
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith({
...intent,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
});
expect(result.provider).toBe("azure-ad");
});
test("extracts reauth intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
intentToken: "intent-token",
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
);
});
test("starts SAML reauthentication with forced-authentication params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result).toEqual({
authorizationParams: {
forceAuthn: "true",
product: "formbricks",
provider: "saml",
tenant: "formbricks.com",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "saml",
});
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "https://evil.example/phish",
userId: intent.userId,
});
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith(
expect.objectContaining({
returnToUrl: "http://localhost:3000",
})
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(InvalidInputError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: "attacker@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: null,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("fails SSO start when the intent cannot be cached", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects a mismatched SSO callback before reading or consuming the intent", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is not for an SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "email",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "github",
providerAccountId: "github-account-id",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: "github-account-id",
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks from unsupported account providers", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "credentials",
providerAccountId: intent.providerAccountId,
type: "credentials",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects OIDC callbacks without an auth_time claim", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: jwt.sign({}, "test-secret"),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SAML callback with an AuthnInstant", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects SAML callbacks without an AuthnInstant", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects stale OIDC auth_time claims", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds - 10 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects OIDC auth_time claims too far in the future", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds + 2 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("stores a deletion marker after fresh SSO reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker after fresh SAML reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
mockRedisConsume(storedSamlIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedSamlIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue(null);
mockPrismaUserFindFirst.mockResolvedValue({ id: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockPrismaUserFindFirst).toHaveBeenCalledWith({
where: {
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
},
select: {
id: true,
},
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("fails SSO completion when the provider account belongs to another user", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
...storedIntent,
providerAccountId: "different-provider-account-id",
},
});
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the deletion marker cannot be cached", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
});
test("surfaces cache read failures while validating callbacks", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to read account deletion SSO reauth value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails closed when atomic Redis consumption is unavailable", async () => {
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
const redisEval = vi.fn().mockResolvedValue(
JSON.stringify({
...storedIntent,
completedAt: Date.now(),
})
);
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects unexpected Redis values while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockRejectedValue(new Error("Redis consume failed")),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Redis consume failed");
});
test("rejects a marker for a different provider account", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
providerAccountId: "different-provider-account-id",
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,664 @@
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 OIDC_REAUTH_PROVIDERS = 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 FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "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 (!FRESH_SSO_REAUTH_PROVIDERS.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 (OIDC_REAUTH_PROVIDERS.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 (!OIDC_REAUTH_PROVIDERS.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,167 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
consumeAccountDeletionSsoReauthentication: vi.fn(),
deleteUser: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
getUser: vi.fn(),
getUserAuthenticationData: vi.fn(),
loggerWarn: vi.fn(),
verifyUserPassword: vi.fn(),
}));
const user = {
email: "delete-user@example.com",
id: "user-id",
};
const oldUser = {
...user,
name: "Delete User",
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
} = {}) => {
vi.resetModules();
vi.doMock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
},
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
}));
vi.doMock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: mocks.getOrganizationsWhereUserIsSingleOwner,
}));
vi.doMock("@/lib/user/password", () => ({
getUserAuthenticationData: mocks.getUserAuthenticationData,
verifyUserPassword: mocks.verifyUserPassword,
}));
vi.doMock("@/lib/user/service", () => ({
deleteUser: mocks.deleteUser,
getUser: mocks.getUser,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
consumeAccountDeletionSsoReauthentication: mocks.consumeAccountDeletionSsoReauthentication,
}));
vi.doMock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
return import("./account-deletion");
};
describe("deleteUserWithAccountDeletionAuthorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.consumeAccountDeletionSsoReauthentication.mockResolvedValue(undefined);
mocks.deleteUser.mockResolvedValue(undefined);
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValue([]);
mocks.getUser.mockResolvedValue(oldUser);
mocks.getUserAuthenticationData.mockResolvedValue({
email: user.email,
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
});
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
identityProvider: "google",
providerAccountId: "google-account-id",
userId: user.id,
});
expect(mocks.getUser).toHaveBeenCalledBefore(mocks.consumeAccountDeletionSsoReauthentication);
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledBefore(mocks.deleteUser);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("still requires password confirmation for password-backed users when the SSO bypass is enabled", async () => {
mocks.getUserAuthenticationData.mockResolvedValueOnce({
email: user.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
password: "correct-password",
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "correct-password");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("does not consume the SSO marker when organization checks reject deletion", async () => {
mocks.getIsMultiOrgEnabled.mockResolvedValueOnce(false);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValueOnce([{ id: "organization-id" }]);
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).rejects.toThrow("You are the only owner of this organization");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
});
@@ -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 };
};
+162 -2
View File
@@ -53,6 +53,13 @@ vi.mock("@/lib/jwt", () => ({
verifyToken: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: vi.fn(),
getAccountDeletionSsoReauthFailureRedirectUrl: vi.fn(),
getAccountDeletionSsoReauthIntentFromCallbackUrl: vi.fn(),
validateAccountDeletionSsoReauthenticationCallback: vi.fn(),
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
@@ -175,7 +182,7 @@ describe("authOptions", () => {
);
}, 15000);
test("should throw error if user has no password stored", async () => {
test("should throw generic invalid credentials error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
@@ -186,7 +193,7 @@ describe("authOptions", () => {
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
"Invalid credentials"
);
}, 15000);
@@ -691,6 +698,159 @@ describe("authOptions", () => {
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn().mockResolvedValueOnce(undefined);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi.fn().mockResolvedValueOnce(undefined);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockGetAccountDeletionSsoReauthIntentFromCallbackUrl).toHaveBeenCalled();
expect(mockValidateAccountDeletionSsoReauthenticationCallback).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockCompleteAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn();
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl: mockGetAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
expect(mockCompleteAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+162 -42
View File
@@ -16,6 +16,12 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
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 { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
@@ -35,6 +41,127 @@ import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
type TSignInCallbackParams = Parameters<NonNullable<NonNullable<NextAuthOptions["callbacks"]>["signIn"]>>[0];
type TSignInUser = TSignInCallbackParams["user"];
type TSignInAccount = TSignInCallbackParams["account"];
type TCredentialsOrTokenAccount = NonNullable<TSignInAccount> & { provider: "credentials" | "token" };
const getValidatedAuthCallbackUrl = async () => {
const cookieStore = await cookies();
return getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
};
const getAuthFlowPurpose = (user: TSignInUser) => {
const authFlowPurpose = "authFlowPurpose" in user ? user.authFlowPurpose : undefined;
return typeof authFlowPurpose === "string" ? authFlowPurpose : undefined;
};
const isCredentialsOrTokenProvider = (account: TSignInAccount): account is TCredentialsOrTokenAccount =>
account?.provider === "credentials" || account?.provider === "token";
const assertCredentialsUserCanSignIn = (user: TSignInUser) => {
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
};
const handleCredentialsOrTokenSignIn = async ({
account,
user,
userEmail,
userId,
}: {
account: TCredentialsOrTokenAccount;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
const isSsoRecovery = account.provider === "token" && getAuthFlowPurpose(user) === "sso_recovery";
if (!isSsoRecovery) {
assertCredentialsUserCanSignIn(user);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
};
const handleEnterpriseSsoSignIn = async ({
account,
callbackUrl,
intentToken,
user,
userEmail,
userId,
}: {
account: NonNullable<TSignInAccount>;
callbackUrl: string;
intentToken: string | null;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
};
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
@@ -117,7 +244,7 @@ export const authOptions: NextAuthOptions = {
if (!user.password) {
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
throw new Error("User has no password stored");
throw new Error("Invalid credentials");
}
if (user.isActive === false) {
@@ -340,53 +467,46 @@ export const authOptions: NextAuthOptions = {
return session;
},
async signIn({ user, account }) {
const cookieStore = await cookies();
// get callback url from the cookie store,
const callbackUrl =
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
const callbackUrl = await getValidatedAuthCallbackUrl();
const accountDeletionSsoReauthIntentToken =
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
const userEmail = user.email ?? "";
const userId = user.id as string;
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
const userId = user.id;
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
const result = await handleSsoCallback({
user: user as TUser,
if (isCredentialsOrTokenProvider(account)) {
return handleCredentialsOrTokenSignIn({
account,
callbackUrl,
user,
userEmail,
userId,
});
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
}
if (ENTERPRISE_LICENSE_KEY && account) {
try {
return await handleEnterpriseSsoSignIn({
account,
callbackUrl,
intentToken: accountDeletionSsoReauthIntentToken,
user,
userEmail,
userId,
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
if (failureRedirectUrl) {
return failureRedirectUrl;
}
throw error;
}
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
+35 -12
View File
@@ -13,8 +13,8 @@ import {
} from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
@@ -116,6 +116,15 @@ async function handleInviteAcceptance(
role: invite.role,
});
try {
const invitedOrganization = await getOrganization(invite.organizationId);
if (invitedOrganization) {
groupIdentifyPostHog("organization", invitedOrganization.id, { name: invitedOrganization.name });
}
} catch (error) {
logger.warn({ error, organizationId: invite.organizationId }, "Failed to identify org group in PostHog");
}
if (invite.teamIds) {
await createTeamMembership(
{
@@ -166,10 +175,17 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
groupIdentifyPostHog("organization", organization.id, { name: organization.name });
capturePostHogEvent(
user.id,
"organization_created",
{
organization_id: organization.id,
is_first_org: true,
},
{ organizationId: organization.id }
);
await updateUser(user.id, {
notificationSettings: {
@@ -236,12 +252,19 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
capturePostHogEvent(user.id, "user_signed_up", {
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
});
capturePostHogEvent(
user.id,
"user_signed_up",
{
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
},
ctx.auditLoggingCtx.organizationId
? { organizationId: ctx.auditLoggingCtx.organizationId }
: undefined
);
}
if (user) {
@@ -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,189 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cache } from "@/lib/cache";
import {
consumeSamlAuthnInstantForCode,
getSamlAuthnInstantFromResponse,
getSamlAuthnInstantFromXml,
storeSamlAuthnInstantFromSamlResponse,
} from "./authn-instant";
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@boxyhq/saml20", () => ({
default: {
decryptXml: vi.fn(),
parseIssuer: vi.fn(),
validateSignature: vi.fn(),
},
}));
vi.mock("@boxyhq/saml-jackson/dist/saml/x509", () => ({
getDefaultCertificate: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const saml20 = await import("@boxyhq/saml20");
const x509 = await import("@boxyhq/saml-jackson/dist/saml/x509");
const mockCache = vi.mocked(cache);
const mockSaml20 = vi.mocked(saml20.default);
const mockGetDefaultCertificate = vi.mocked(x509.getDefaultCertificate);
const connectionController = {
getConnections: vi.fn(),
};
const encodeSamlResponse = (xml: string) => Buffer.from(xml, "utf8").toString("base64");
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const signedSamlResponse = `
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
`;
describe("SAML AuthnInstant handoff", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockGetDefaultCertificate.mockResolvedValue({
privateKey: "sp-private-key",
publicKey: "sp-public-key",
});
mockSaml20.parseIssuer.mockReturnValue("https://idp.example.com/metadata");
mockSaml20.decryptXml.mockReturnValue({ assertion: signedSamlResponse, decrypted: true });
mockSaml20.validateSignature.mockReturnValue(signedSamlResponse);
connectionController.getConnections.mockResolvedValue([
{
idpMetadata: {
publicKey: "trusted-public-key",
},
},
]);
});
test("extracts and normalizes AuthnInstant from signed SAML XML", () => {
expect(getSamlAuthnInstantFromXml(signedSamlResponse)).toBe("2026-05-04T12:30:00.000Z");
});
test("extracts AuthnInstant from the signature-validated SAML response", async () => {
const samlResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:00:00Z" />
</saml:Assertion>
</samlp:Response>
`;
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(samlResponse),
})
).resolves.toBe("2026-05-04T12:30:00.000Z");
expect(mockSaml20.validateSignature).toHaveBeenCalledWith(samlResponse, "trusted-public-key", null);
});
test("extracts AuthnInstant from encrypted signature-validated SAML responses", async () => {
const encryptedSignedResponse = `
<samlp:Response>
<saml:EncryptedAssertion>encrypted-assertion</saml:EncryptedAssertion>
</samlp:Response>
`;
const decryptedSignedResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:45:00Z" />
</saml:Assertion>
</samlp:Response>
`;
mockSaml20.validateSignature.mockReturnValue(encryptedSignedResponse);
mockSaml20.decryptXml.mockReturnValue({ assertion: decryptedSignedResponse, decrypted: true });
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(encryptedSignedResponse),
})
).resolves.toBe("2026-05-04T12:45:00.000Z");
expect(mockGetDefaultCertificate).toHaveBeenCalled();
expect(mockSaml20.decryptXml).toHaveBeenCalledWith(encryptedSignedResponse, {
privateKey: "sp-private-key",
});
});
test("stores signed AuthnInstant by the one-time OAuth code from the Jackson redirect", async () => {
const samlResponse = encodeSamlResponse(`
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
</samlp:Response>
`);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse,
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
{ authnInstant: "2026-05-04T12:30:00.000Z" },
5 * 60 * 1000
);
const cacheKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
});
test("does not store when the signed SAML XML has no AuthnInstant", async () => {
mockSaml20.validateSignature.mockReturnValue("<saml:Assertion />");
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not store when the SAML signature cannot be validated with known IdP metadata", async () => {
mockSaml20.validateSignature.mockReturnValue(null);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("consumes a stored AuthnInstant for the token response", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
authnInstant: "2026-05-04T12:30:00.000Z",
},
});
await expect(consumeSamlAuthnInstantForCode("oauth-code")).resolves.toBe("2026-05-04T12:30:00.000Z");
const cacheKey = mockCache.get.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
expect(mockCache.del).toHaveBeenCalledWith([cacheKey]);
});
});
@@ -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;
};
+26 -11
View File
@@ -220,9 +220,14 @@ export const startHobbyAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(ctx.user.id, "stayed_on_hobby_plan", {
organization_id: parsedInput.organizationId,
});
capturePostHogEvent(
ctx.user.id,
"stayed_on_hobby_plan",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
return { success: true };
});
@@ -257,15 +262,25 @@ export const startProTrialAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(ctx.user.id, "free_trial_started", {
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
});
capturePostHogEvent(
ctx.user.id,
"free_trial_started",
{
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "reverse_trial_started", {
organization_id: parsedInput.organizationId,
});
capturePostHogEvent(
ctx.user.id,
"reverse_trial_started",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
return { success: true };
});
@@ -3,6 +3,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
@@ -54,6 +55,17 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
throw new InvalidInputError(errorMessage);
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
},
{ organizationId, workspaceId: projectId }
);
return {
surveyUrl: result.data,
};
+20
View File
@@ -1,9 +1,11 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -113,6 +115,10 @@ export const createContactsFromCSVAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const existingContactCount = await prisma.contact.count({
where: { environmentId: parsedInput.environmentId },
});
const result = await createContactsFromCSV(
parsedInput.csvData,
parsedInput.environmentId,
@@ -124,6 +130,20 @@ export const createContactsFromCSVAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = {
contacts: result.contacts,
};
capturePostHogEvent(
ctx.user.id,
"contact_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
existing_contact_count: existingContactCount,
creation_method: "import",
import_count: result.contacts.length,
},
{ organizationId, workspaceId: projectId }
);
}
return result;
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -62,6 +63,18 @@ export const createContactAttributeKeyAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = contactAttributeKey;
capturePostHogEvent(
ctx.user.id,
"contact_attribute_key_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
key: parsedInput.key,
},
{ organizationId, workspaceId: projectId }
);
return contactAttributeKey;
})
);

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