Compare commits

..

41 Commits

Author SHA1 Message Date
Tiago Farto 0834f0a849 chore: polish SSO confirmation terminology 2026-05-14 13:53:18 +00:00
Tiago Farto 0cb2d2b3d2 chore: backport SSO confirmation env rename 2026-05-14 13:50:17 +00:00
Tiago Farto 98abc421e4 chore: backport SSO deletion simplification 2026-05-14 13:47:15 +00:00
Dhruwang Jariwala 77a21d1eab fix: backport SSO account deletion reauth (#7976) 2026-05-13 11:28:36 +05:30
Dhruwang 613c91a719 Merge branch 'release/4.9' of https://github.com/formbricks/formbricks into backport/7930-sso-account-deletion
# Conflicts:
#	pnpm-lock.yaml
2026-05-13 10:44:58 +05:30
Dhruwang Jariwala ca372b3c8b fix: patch security dependency vulnerabilities for 4.9 (#7989) 2026-05-13 10:37:33 +05:30
Matti Nannt 80e1cc2411 fix: patch transitive and direct dependency security vulnerabilities for 4.9
Updates direct dependencies with known CVEs and adds/tightens pnpm overrides
for transitive dependencies that cannot be updated directly.

Direct updates:
- next: 16.1.7 → 16.2.6 (middleware bypass, SSRF, DoS, XSS CVEs)
- lodash: 4.17.23 → 4.18.1 (code injection via template CVE-2025-62616)
- nodemailer: 8.0.2 → 8.0.7 (SMTP injection CVEs)
- uuid: 13.0.0 → 13.0.2 (buffer bounds check CVE)
- postcss: 8.5.8 → 8.5.14 (XSS via unescaped </style> CVE-2025-62695)
- @opentelemetry suite: 0.213.0 → 0.217.0 / 2.6.0 → 2.7.1

Override additions/updates:
- protobufjs@7: 7.5.8, protobufjs@8: 8.2.0 (GHSA-xq3m-2v4x-88gg arbitrary code execution)
- @protobufjs/utf8: 1.1.1 (overlong UTF-8 CVE)
- vite@7: 7.3.3, vite@8: 8.0.12 (GHSA-v2wj-q39q-566r fs.deny bypass, GHSA-p9ff-h696-f583 file read)
- node-forge: 1.4.0 (multiple signature forgery / DoS CVEs)
- defu: 6.1.7 (prototype pollution CVE-2025-62629)
- brace-expansion@1/2/5: patched (ReDoS CVE-2025-67313)
- picomatch@2/4: patched (ReDoS CVE-2025-60538/63394)
- dompurify: 3.4.2 (XSS CVE-2025-26791)
- ip-address: 10.1.1 (ReDoS CVE-2025-62629)
- fast-uri: 3.1.2 (CVE-2025-48944/48945)
- fast-xml-parser: 5.7.0 (multiple CVEs)
- yaml: 2.8.3 (CVE-2025-63675)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:33:15 +02:00
Dhruwang fef959e9aa test: strip backport-only test additions
Backport PRs should not introduce new test files or test cases beyond
what already exists on the release branch — even when those tests live
on main. Coverage for the underlying functionality remains on main via
the original PR (#7930); duplicating it here only inflates the backport
diff.

Removes 7 added test files and 3 test cases that were added to
jwt.test.ts (account deletion SSO reauthentication intents). Existing
test file updates (user.test.ts cleanup, providers.test.ts and
keys.test.ts trivial adjustments) are kept. No production code touched.
2026-05-12 18:59:07 +05:30
Tiago Farto 240ce70feb test: restore sso reauth coverage 2026-05-12 12:21:58 +00:00
Tiago Farto c16a77fd66 test: restore scoped sso deletion coverage 2026-05-12 12:10:35 +00:00
Tiago Farto f33cfcd11f test: fix sso backport expectations 2026-05-12 11:46:17 +00:00
Tiago Farto a164fb213f test: cover sso account deletion backport 2026-05-12 10:57:40 +00:00
Tiago Farto d3cf3f05f2 chore: trim release backport scope 2026-05-12 10:42:35 +00:00
Tiago Farto 261d2050fc test: isolate authenticated api client 2026-05-11 15:05:34 +00:00
Tiago 5b26354f48 fix: sso account deletion password check (#7930)
(cherry picked from commit 69ead97965)
2026-05-11 14:52:01 +00:00
Dhruwang Jariwala bd05387d99 fix: backport account deletion authorization (#7901) (#7903) 2026-04-28 18:39:00 +05:30
Tiago Farto 9b4be60dd9 fix: backport account deletion authorization (#7901) 2026-04-28 12:52:06 +00:00
Dhruwang Jariwala bad3b7a771 fix: (backport) prevent SSRF via redirect following in webhook delivery (#7877) (#7892) 2026-04-27 15:32:12 +05:30
Dhruwang Jariwala 007d99f6b8 fix: prevent Airtable integration crash when token expires (backport #7811) (#7873) 2026-04-27 15:32:03 +05:30
Dhruwang Jariwala 03b7dfefe4 fix: fixes sentry ref issue (backport #7776) (#7872) 2026-04-27 15:31:52 +05:30
Anshuman Pandey 9178558ba1 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 15:08:17 +05:30
Dhruwang Jariwala a65e6d9093 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 11:02:04 +05:30
Anshuman Pandey 592d36542f fix: fixes sentry ref issue (#7776) 2026-04-27 11:01:17 +05:30
Tiago 5ec8218666 fix: (backport) password hash visibility improvement (#7814) (#7833) 2026-04-24 14:33:26 +00:00
Tiago Farto e1a44817f2 fix: password hash visibility improvement
(cherry picked from commit 73ad130ece)
2026-04-24 13:10:40 +00:00
Dhruwang Jariwala 7f5b2bf69d fix: prevent split offline responses on restore (backport #7767) (#7777) 2026-04-20 12:00:34 +05:30
Dhruwang 60e7c7e8ee fix(surveys): prevent split offline responses on restore (backport #7767)
Backport of #7767 to release/4.9. Anchors displayId and responseId back
into saved survey progress as soon as they are created, recovers a
missing responseId from displayId on restore, and falls back to a
bootstrap create path that uses the full accumulated response state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:43:46 +05:30
Dhruwang Jariwala 7988d7775c fix: [backport] remove dark: variant classes from survey-ui to prevent host page style leakage (#7748) 2026-04-16 11:20:33 +05:30
Dhruwang Jariwala b7ede6c578 fix: prevent offline replay from dropping survey blocks after completion (#7744) 2026-04-15 22:00:29 +02:00
Bhagya Amarasinghe 8204a5c652 fix: restore legacy SSO auto-linking hotfix (#7728) 2026-04-13 20:42:33 +05:30
Anshuman Pandey e823e10f9a fix: backports missing posthog events fix (#7723) 2026-04-13 17:36:39 +05:30
Dhruwang Jariwala f5c3212b2c revert: enhance welcome card to support video uploads (backport #7712) (#7720)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:59:20 +05:30
Dhruwang Jariwala 2d66fc6987 fix: prevent TTC overcount for multi-question blocks (backport #7713) (#7719) 2026-04-13 14:40:35 +05:30
Dhruwang Jariwala 652970003d fix: validate "Other" option text on required questions and remove duplicate response entry (backport #7716) (#7717) 2026-04-13 12:27:08 +04:00
Dhruwang Jariwala a8b5e286b6 fix: only show beforeunload warning when offline support is active (backport #7715) (#7718) 2026-04-13 12:26:30 +04:00
Dhruwang Jariwala 322f0be197 fix: improve restricted ID validation toast with i18n support (#7703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-12 06:18:13 +00:00
Manuel Delgado 1a02f91afd fix(api): return 409 Conflict instead of 500 when creating user with duplicate email (#7675)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-10 14:28:17 +00:00
Tiago cc22ccb22d chore: Harden SSO account linking for existing email-based accounts (#7702) 2026-04-10 14:19:21 +00:00
Tiago 12763f0ef6 fix: Dutch translations for link survey footer (Privacy Policy, Imprint, Report Survey) (#7707) 2026-04-10 13:42:15 +00:00
Dhruwang Jariwala d39e3ee638 feat: offline support for link surveys (#7694)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-10 11:27:48 +00:00
dingdyan d85242a86b fix: handle internal server error toast behavior in create organization (#7662)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-10 11:13:10 +00:00
183 changed files with 6925 additions and 1861 deletions
+8
View File
@@ -106,6 +106,14 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion SSO confirmation #
###########################################
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
##########
# Other #
+13 -1
View File
@@ -1 +1,13 @@
pnpm lint-staged
#!/usr/bin/env sh
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
@@ -6,11 +6,9 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,30 +1,65 @@
"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_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} 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 !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
return;
}
hasShownAccountDeletionError.current = true;
toast.error(t("environments.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-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 +67,7 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,12 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
import { getIsEmailUnique } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -17,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -1,42 +1,5 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -15,10 +16,14 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { environmentId } = params;
@@ -33,6 +38,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -90,6 +96,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading}
/>
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10, mockQuotas);
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
finished: boolean;
}
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[],
displayCount: number,
quotas: TSurveySummary["quotas"]
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => {
if (response.ttc?._total) {
const blockTimes = getBlockTimesForResponse(response, survey);
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++;
return acc + response.ttc._total;
return acc + responseTtcTotal;
}
return acc;
}, 0);
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
responses.forEach((response) => {
// Calculate total time-to-completion per element
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
const blockId = elementIdToBlockId[elementId];
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++;
}
});
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const [meta, elementSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
return {
meta,
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
const tableHeaders = [
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
))}
</div>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
}
if (isReadOnly) {
return redirect("./");
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -0,0 +1,88 @@
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 {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { authOptions } from "@/modules/auth/lib/authOptions";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeFailureRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
parsedReturnToUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let deletionSucceeded = false;
let redirectPath = "/auth/login";
let targetUserId: string | null = null;
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
targetUserId = verifiedIntent.userId;
redirectPath = getSafeFailureRedirectPath(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 identity confirmation session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
deletionSucceeded = true;
redirectPath = getPostDeletionRedirectPath();
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
} catch (error) {
if (targetUserId && !deletionSucceeded) {
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
}
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
}
return redirectPath;
};
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoConfirmationCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath(await searchParams));
}
@@ -0,0 +1,33 @@
import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
surveyId: string;
surveyType: string;
environmentId: string;
responseCount: number;
}
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
+17 -23
View File
@@ -1,7 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -93,10 +91,15 @@ export const POST = async (request: Request) => {
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
// Fetch with timeout of 5 seconds to prevent hanging.
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -302,25 +305,16 @@ export const POST = async (request: Request) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Sampled PostHog tracking: first response + every 100th
if (POSTHOG_KEY) {
const responseCount = await cache.withCache(
() => getResponseCountBySurveyId(surveyId),
createCacheKey.response.countBySurveyId(surveyId),
60 * 1000
);
const responseCount = await getResponseCountBySurveyId(surveyId);
if (responseCount === 1 || responseCount % 100 === 0) {
capturePostHogEvent(organization.id, "survey_response_received", {
survey_id: surveyId,
survey_type: survey.type,
organization_id: organization.id,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
}
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
surveyId,
surveyType: survey.type,
environmentId,
responseCount,
});
}
// Send telemetry events
@@ -1,9 +1,18 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
type WrappedAuthOptions = {
callbacks: {
signIn: (params: { user: unknown; account: unknown }) => Promise<boolean | string>;
};
events: {
signIn: (params: { user: unknown; account: unknown; isNewUser: boolean }) => Promise<void>;
};
};
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
const nextAuth = vi.fn((_authOptions: WrappedAuthOptions) => nextAuthHandler);
return {
nextAuth,
@@ -54,7 +63,7 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<WrappedAuthOptions> => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
@@ -63,7 +72,17 @@ const getWrappedAuthOptions = async (requestId: string = "req-123") => {
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
const firstCall = mocks.nextAuth.mock.calls.at(0);
if (!firstCall) {
throw new Error("NextAuth was not called");
}
const [authOptions] = firstCall;
if (!authOptions) {
throw new Error("NextAuth options were not provided");
}
return authOptions;
};
describe("auth route audit logging", () => {
@@ -136,4 +155,34 @@ describe("auth route audit logging", () => {
})
);
});
test("logs blocked SSO account-linking attempts as SSO failures", async () => {
const error = new Error("OAuthAccountNotLinked");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-sso-failure");
const user = { id: "user_3", email: "user3@example.com" };
const account = { provider: "google" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("OAuthAccountNotLinked");
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_3",
targetId: "user_3",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-sso-failure",
newObject: expect.objectContaining({
email: "user3@example.com",
authMethod: "sso",
provider: "google",
errorMessage: "OAuthAccountNotLinked",
}),
})
);
});
});
@@ -1,5 +1,6 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
@@ -10,6 +11,8 @@ import {
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -5,7 +5,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -76,16 +78,31 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
data: existingData,
email,
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
@@ -1,8 +1,7 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = await getIntegrationByType(environmentId, "airtable");
if (!integration) {
return {
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
};
}
const tables = await getTables(integration.config.key, baseId.data);
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(environmentId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
return {
response: responses.successResponse(tables),
};
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -11,6 +12,8 @@ import {
import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import {
TIntegrationSlackConfig,
TIntegrationSlackConfigData,
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { publicUserSelect } from "@/lib/user/public-user";
import { GET } from "./route";
const mocks = vi.hoisted(() => ({
headers: vi.fn(),
getSessionUser: vi.fn(),
parseApiKeyV2: vi.fn(),
hashSha256: vi.fn(),
verifySecret: vi.fn(),
applyRateLimit: vi.fn(),
notAuthenticatedResponse: vi.fn(
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
),
tooManyRequestsResponse: vi.fn(
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
),
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
}));
vi.mock("next/headers", () => ({
headers: mocks.headers,
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: mocks.getSessionUser,
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
badRequestResponse: mocks.badRequestResponse,
},
}));
vi.mock("@/lib/crypto", () => ({
hashSha256: mocks.hashSha256,
parseApiKeyV2: mocks.parseApiKeyV2,
verifySecret: mocks.verifySecret,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v1: { windowMs: 60_000, max: 1000 },
},
},
}));
const getMockHeaders = (apiKey: string | null) => ({
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
});
describe("v1 management me route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.headers.mockResolvedValue(getMockHeaders(null));
mocks.getSessionUser.mockResolvedValue(undefined);
mocks.parseApiKeyV2.mockReturnValue(null);
mocks.hashSha256.mockReturnValue("hashed-api-key");
mocks.verifySecret.mockResolvedValue(false);
mocks.applyRateLimit.mockResolvedValue(undefined);
});
test("returns a sanitized authenticated user for session-based requests", async () => {
const publicUser = {
id: "user_123",
name: "Test User",
email: "test@example.com",
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
createdAt: new Date("2025-04-17T20:09:14.021Z"),
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
twoFactorEnabled: false,
identityProvider: "email" as const,
notificationSettings: {
alert: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US" as const,
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
isActive: true,
};
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
expect(responseBody).not.toHaveProperty("password");
expect(responseBody).not.toHaveProperty("twoFactorSecret");
expect(responseBody).not.toHaveProperty("backupCodes");
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: publicUser.id },
select: publicUserSelect,
});
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
});
test("returns the existing unauthenticated response when no session is present", async () => {
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(401);
expect(responseBody).toEqual({ message: "Not authenticated" });
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("preserves the API key response path", async () => {
const apiKeyData = {
id: "api_key_123",
organizationId: "org_123",
hashedKey: "stored-hash",
lastUsedAt: new Date(),
apiKeyEnvironments: [
{
permission: "manage",
environment: {
id: "env_123",
type: "development",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
projectId: "project_123",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
},
},
],
};
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
const response = await GET();
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody).toStrictEqual({
id: "env_123",
type: "development",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
appSetupCompleted: true,
project: {
id: "project_123",
name: "My Project",
},
});
expect(mocks.getSessionUser).not.toHaveBeenCalled();
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
});
});
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
+24 -2
View File
@@ -51,6 +51,8 @@ checksums:
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
@@ -292,6 +294,9 @@ checksums:
common/notifications: c52df856139b50dbb1cae7bfb1cf73bb
common/number: 2789f8391f63e7200a5521078aab017d
common/off: 7e33a70258401abe652c9f1b595fabda
common/offline_all_responses_synced: 9760250890a3a1901163c3f9e91602cc
common/offline_syncing_responses: 6a518ff0248a29216b60a206e6966d4d
common/offline_you_are_offline: c8fa81ac3e9cad1c64b963ec3f372085
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
@@ -408,6 +413,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -778,6 +784,9 @@ checksums:
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
@@ -1070,11 +1079,15 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
environments/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
environments/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
environments/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
@@ -1115,6 +1128,7 @@ checksums:
environments/settings/general/only_org_owner_can_perform_action: 0244ff3c6de787935e592eac4c5e4f0b
environments/settings/general/organization_created_successfully: 1ce874980bdd7d5de8402c276fb97a57
environments/settings/general/organization_deleted_successfully: e51fd7ee9efda04a373450ea21b242db
environments/settings/general/organization_deletion_disabled: 6fb0e71c218ed4ab3be175f1094feada
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
@@ -1165,6 +1179,8 @@ checksums:
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
environments/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
environments/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -1698,6 +1714,11 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validate_id_duplicate: f88ec35a9bd4921fb096817b9263b59a
environments/surveys/edit/validate_id_empty: 3ee25d429ed5ca9e047f9aee95496323
environments/surveys/edit/validate_id_invalid_chars: 50239938a408c04b02d77b8cd096d767
environments/surveys/edit/validate_id_no_spaces: 11c4408574c11c51f30fe98be18baadb
environments/surveys/edit/validate_id_reserved: 2696af4715ee91539e247b1b9a931721
environments/surveys/edit/validation/add_validation_rule: e0c3208977140e5475df3e9b08927dbf
environments/surveys/edit/validation/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0
environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259
@@ -2008,6 +2029,7 @@ checksums:
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
@@ -2466,8 +2488,8 @@ checksums:
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
templates/csat_question_2_choice_1: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_2: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
+10 -3
View File
@@ -1,9 +1,13 @@
import { Prisma } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
type TAccountDbClient = PrismaClient | Prisma.TransactionClient;
const getDbClient = (tx?: Prisma.TransactionClient): TAccountDbClient => tx ?? prisma;
export const createAccount = async (accountData: TAccountInput): Promise<TAccount> => {
validateInputs([accountData, ZAccountInput]);
@@ -21,7 +25,10 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
export const upsertAccount = async (
accountData: TAccountInput,
tx?: Prisma.TransactionClient
): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
@@ -33,7 +40,7 @@ export const upsertAccount = async (accountData: TAccountInput): Promise<TAccoun
};
try {
const account = await prisma.account.upsert({
const account = await getDbClient(tx).account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
+11 -5
View File
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return res;
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
+2
View File
@@ -26,6 +26,8 @@ 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_CONFIRMATION =
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "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";
+2
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_CONFIRMATION: 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(),
@@ -267,6 +268,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_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
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,
+11 -3
View File
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import {
TIntegration,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? transformIntegration(integration) : null;
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+86 -1
View File
@@ -1,4 +1,4 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
@@ -266,6 +266,91 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
return { id: userData.userId, email: userData.userEmail };
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createAccountDeletionSsoReauthIntent = (
payload: TAccountDeletionSsoReauthIntentPayload,
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
purpose: payload.purpose,
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifyAccountDeletionSsoReauthIntent = (
token: string
): TAccountDeletionSsoReauthIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
userId: string;
email: string;
provider: string;
providerAccountId: string;
purpose: string;
returnToUrl: string;
};
if (
!payload?.id ||
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
payload?.purpose !== "account_deletion_sso_reauth" ||
!payload?.returnToUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
purpose: "account_deletion_sso_reauth",
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
};
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
+27
View File
@@ -68,6 +68,33 @@ describe("Membership Service", () => {
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
});
test("uses the transaction client directly when provided", async () => {
const mockMembership: TMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "owner",
};
const tx = {
membership: {
findUnique: vi.fn().mockResolvedValue(mockMembership),
},
} as any;
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId, tx);
expect(result).toEqual(mockMembership);
expect(tx.membership.findUnique).toHaveBeenCalledWith({
where: {
userId_organizationId: {
userId: mockUserId,
organizationId: mockOrgId,
},
},
});
expect(prisma.membership.findUnique).not.toHaveBeenCalled();
});
});
describe("createMembership", () => {
+49 -25
View File
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -8,43 +8,67 @@ import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "../utils/validate";
export const getMembershipByUserIdOrganizationId = reactCache(
async (userId: string, organizationId: string): Promise<TMembership | null> => {
validateInputs([userId, ZString], [organizationId, ZString]);
type TMembershipDbClient = PrismaClient | Prisma.TransactionClient;
try {
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
const getDbClient = (tx?: Prisma.TransactionClient): TMembershipDbClient => tx ?? prisma;
const getMembershipByUserIdOrganizationIdUncached = async (
userId: string,
organizationId: string,
tx?: Prisma.TransactionClient
): Promise<TMembership | null> => {
validateInputs([userId, ZString], [organizationId, ZString]);
try {
const membership = await getDbClient(tx).membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
});
},
});
if (!membership) return null;
if (!membership) return null;
return membership;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting membership by user id and organization id");
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching membership");
return membership;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting membership by user id and organization id");
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching membership");
}
};
const getMembershipByUserIdOrganizationIdCached = reactCache(async (userId: string, organizationId: string) =>
getMembershipByUserIdOrganizationIdUncached(userId, organizationId)
);
export const getMembershipByUserIdOrganizationId = async (
userId: string,
organizationId: string,
tx?: Prisma.TransactionClient
): Promise<TMembership | null> => {
if (tx) {
return getMembershipByUserIdOrganizationIdUncached(userId, organizationId, tx);
}
return getMembershipByUserIdOrganizationIdCached(userId, organizationId);
};
export const createMembership = async (
organizationId: string,
userId: string,
data: Partial<TMembership>
data: Partial<TMembership>,
tx?: Prisma.TransactionClient
): Promise<TMembership> => {
validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]);
try {
const existingMembership = await prisma.membership.findUnique({
const prismaClient = getDbClient(tx);
const existingMembership = await prismaClient.membership.findUnique({
where: {
userId_organizationId: {
userId,
@@ -59,7 +83,7 @@ export const createMembership = async (
let membership: TMembership;
if (!existingMembership) {
membership = await prisma.membership.create({
membership = await prismaClient.membership.create({
data: {
userId,
organizationId,
@@ -68,7 +92,7 @@ export const createMembership = async (
},
});
} else {
membership = await prisma.membership.update({
membership = await prismaClient.membership.update({
where: {
userId_organizationId: {
userId,
+2 -6
View File
@@ -1,8 +1,4 @@
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const notionIntegration = await getIntegrationByType(environmentId, "notion");
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+5 -1
View File
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
return responseValue
.filter((v) => v !== "")
.map(findChoiceByLabel)
.filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+40
View File
@@ -0,0 +1,40 @@
import "server-only";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserAuthenticationData = reactCache(
async (
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (!user.password) {
throw new InvalidInputError("Password is not set for this user");
}
return await verifyPassword(password, user.password);
};
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
+12 -10
View File
@@ -1,7 +1,7 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
import { getValidatedCallbackUrl, isStringUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
@@ -72,23 +72,25 @@ describe("testURLmatch", () => {
});
});
describe("isValidCallbackUrl", () => {
describe("getValidatedCallbackUrl", () => {
const WEBAPP_URL = "https://webapp.example.com";
test("returns true for valid callback URL", () => {
expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true);
test("returns the normalized callback URL for a valid callback", () => {
expect(getValidatedCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(
"https://webapp.example.com/callback"
);
});
test("returns false for invalid scheme", () => {
expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false);
test("returns null for invalid scheme", () => {
expect(getValidatedCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBeNull();
});
test("returns false for invalid domain", () => {
expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false);
test("returns null for invalid domain", () => {
expect(getValidatedCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBeNull();
});
test("returns false for malformed URL", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
test("returns null for malformed URL", () => {
expect(getValidatedCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBeNull();
});
});
+34 -9
View File
@@ -35,18 +35,43 @@ export const testURLmatch = (
};
// Helper function to validate callback URLs
export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean => {
export const getValidatedCallbackUrl = (
url: string | null | undefined,
WEBAPP_URL: string
): string | null => {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
const allowedSchemes = ["https:", "http:"];
// Extract the domain from WEBAPP_URL
const parsedWebAppUrl = new URL(WEBAPP_URL);
const allowedDomains = [parsedWebAppUrl.hostname];
const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url);
const isRootRelativePath = url.startsWith("/");
return allowedSchemes.includes(parsedUrl.protocol) && allowedDomains.includes(parsedUrl.hostname);
} catch (err) {
return false;
// Reject ambiguous non-URL values like "foo" while still allowing safe root-relative paths.
if (!isAbsoluteUrl && !isRootRelativePath) {
return null;
}
const parsedUrl = isAbsoluteUrl ? new URL(url) : new URL(url, parsedWebAppUrl.origin);
const allowedSchemes = ["https:", "http:"];
const allowedOrigins = new Set([parsedWebAppUrl.origin]);
if (!allowedSchemes.includes(parsedUrl.protocol)) {
return null;
}
if (!allowedOrigins.has(parsedUrl.origin)) {
return null;
}
if (parsedUrl.username || parsedUrl.password) {
return null;
}
return parsedUrl.toString();
} catch {
return null;
}
};
+21 -2
View File
@@ -63,6 +63,8 @@
"login_with_email": "Mit E-Mail einloggen",
"lost_access": "Zugang verloren?",
"new_to_formbricks": "Neu bei Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Einen Backup-Code verwenden"
},
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
@@ -319,6 +321,9 @@
"notifications": "Benachrichtigungen",
"number": "Nummer",
"off": "Aus",
"offline_all_responses_synced": "Deine Antwort wurde erfolgreich gespeichert.",
"offline_syncing_responses": "Deine Antworten werden synchronisiert…",
"offline_you_are_offline": "Du bist offline. Deine Antwort ist in deinem Browser gespeichert und wird gesichert, sobald du wieder online bist.",
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Zugriff verloren",
@@ -1236,6 +1246,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_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
"update_personal_info": "Persönliche Daten aktualisieren",
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
"wrong_password": "Falsches Passwort"
},
"teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
@@ -1777,6 +1790,11 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"validate_id_duplicate": "{type}-ID existiert bereits in Fragen, versteckten Feldern oder Variablen.",
"validate_id_empty": "Bitte gib eine {type}-ID ein.",
"validate_id_invalid_chars": "{type}-ID ist nicht erlaubt. Bitte verwende nur alphanumerische Zeichen, Bindestriche oder Unterstriche.",
"validate_id_no_spaces": "{type}-ID darf keine Leerzeichen enthalten. Bitte entferne die Leerzeichen.",
"validate_id_reserved": "{type}-ID \"{field}\" ist nicht erlaubt. Es handelt sich um ein reserviertes Schlüsselwort.",
"validation": {
"add_validation_rule": "Validierungsregel hinzufügen",
"answer_all_rows": "Alle Zeilen beantworten",
@@ -2117,6 +2135,7 @@
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung",
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
"csat_question_1_lower_label": "Nicht wahrscheinlich",
"csat_question_1_upper_label": "Sehr wahrscheinlich",
"csat_question_2_choice_1": "Einigermaßen zufrieden",
"csat_question_2_choice_1": "Eher zufrieden",
"csat_question_2_choice_2": "Sehr zufrieden",
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
"csat_question_2_choice_4": "Etwas unzufrieden",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "Login with Email",
"lost_access": "Lost Access?",
"new_to_formbricks": "New to Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Use a backup code"
},
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
@@ -319,6 +321,9 @@
"notifications": "Notifications",
"number": "Number",
"off": "Off",
"offline_all_responses_synced": "Your response was saved successfully.",
"offline_syncing_responses": "Syncing your responses…",
"offline_you_are_offline": "You're offline. Your response is stored in your browser and will be saved when you're back online.",
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Send data to your Notion database",
"please_select_a_survey_error": "Please select a survey",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
"already_connected_another_survey": "You have already connected another survey to this channel.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Lost access",
@@ -1236,6 +1246,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_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
"update_personal_info": "Update your personal information",
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
"warning_cannot_undo": "This cannot be undone"
"warning_cannot_undo": "This cannot be undone",
"wrong_password": "Wrong password"
},
"teams": {
"add_members_description": "Add members to the team and determine their role.",
@@ -1777,6 +1790,11 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"validate_id_duplicate": "{type} ID already exists in questions, hidden fields, or variables.",
"validate_id_empty": "Please enter a {type} ID.",
"validate_id_invalid_chars": "{type} ID is not allowed. Please use only alphanumeric characters, hyphens, or underscores.",
"validate_id_no_spaces": "{type} ID cannot contain spaces. Please remove spaces.",
"validate_id_reserved": "{type} ID \"{field}\" is not allowed. It is a reserved keyword.",
"validation": {
"add_validation_rule": "Add validation rule",
"answer_all_rows": "Answer all rows",
@@ -2117,6 +2135,7 @@
"this_quarter": "This quarter",
"this_year": "This year",
"time_to_complete": "Time to Complete",
"ttc_survey_tooltip": "Average time to complete the survey.",
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "Iniciar sesión con correo electrónico",
"lost_access": "¿Has perdido el acceso?",
"new_to_formbricks": "¿Nuevo en Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Usar un código de respaldo"
},
"saml_connection_error": "Algo salió mal. Por favor, consulta la consola de tu aplicación para más detalles.",
@@ -319,6 +321,9 @@
"notifications": "Notificaciones",
"number": "Número",
"off": "Desactivado",
"offline_all_responses_synced": "Tu respuesta se guardó correctamente.",
"offline_syncing_responses": "Sincronizando tus respuestas…",
"offline_you_are_offline": "Estás sin conexión. Tu respuesta está almacenada en tu navegador y se guardará cuando vuelvas a estar en línea.",
"on": "Activado",
"only_one_file_allowed": "Solo se permite un archivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Envía datos a tu base de datos de Notion",
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
"slack": {
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Acceso perdido",
@@ -1236,6 +1246,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_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
"update_personal_info": "Actualiza tu información personal",
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
"warning_cannot_undo": "Esto no se puede deshacer"
"warning_cannot_undo": "Esto no se puede deshacer",
"wrong_password": "Contraseña incorrecta"
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
@@ -1777,6 +1790,11 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validate_id_duplicate": "El ID de {type} ya existe en preguntas, campos ocultos o variables.",
"validate_id_empty": "Por favor, introduce un ID de {type}.",
"validate_id_invalid_chars": "El ID de {type} no está permitido. Por favor, utiliza solo caracteres alfanuméricos, guiones o guiones bajos.",
"validate_id_no_spaces": "El ID de {type} no puede contener espacios. Por favor, elimina los espacios.",
"validate_id_reserved": "El ID de {type} \"{field}\" no está permitido. Es una palabra reservada.",
"validation": {
"add_validation_rule": "Añadir regla de validación",
"answer_all_rows": "Responde todas las filas",
@@ -2117,6 +2135,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este año",
"time_to_complete": "Tiempo para completar",
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
"unknown_question_type": "Tipo de pregunta desconocido",
"use_personal_links": "Usar enlaces personales",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "Se connecter avec un e-mail",
"lost_access": "Accès perdu ?",
"new_to_formbricks": "Nouveau sur Formbricks ?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Utiliser un code de secours"
},
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
@@ -319,6 +321,9 @@
"notifications": "Notifications",
"number": "Numéro",
"off": "Éteint",
"offline_all_responses_synced": "Ta réponse a été enregistrée avec succès.",
"offline_syncing_responses": "Synchronisation de vos réponses…",
"offline_you_are_offline": "Tu es hors ligne. Ta réponse est stockée dans ton navigateur et sera enregistrée dès que tu seras de nouveau en ligne.",
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Accès perdu",
@@ -1236,6 +1246,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_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Cette opération est irréversible."
"warning_cannot_undo": "Cette opération est irréversible.",
"wrong_password": "Mot de passe incorrect"
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
@@ -1777,6 +1790,11 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validate_id_duplicate": "L'ID {type} existe déjà dans les questions, champs masqués ou variables.",
"validate_id_empty": "Veuillez saisir un ID {type}.",
"validate_id_invalid_chars": "L'ID {type} n'est pas autorisé. Veuillez utiliser uniquement des caractères alphanumériques, des traits d'union ou des underscores.",
"validate_id_no_spaces": "L'ID {type} ne peut pas contenir d'espaces. Veuillez supprimer les espaces.",
"validate_id_reserved": "L'ID {type} \"{field}\" n'est pas autorisé. C'est un mot-clé réservé.",
"validation": {
"add_validation_rule": "Ajouter une règle de validation",
"answer_all_rows": "Répondre à toutes les lignes",
@@ -2117,6 +2135,7 @@
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
"time_to_complete": "Temps à compléter",
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
+21 -2
View File
@@ -63,6 +63,8 @@
"login_with_email": "Bejelentkezés e-mail-címmel",
"lost_access": "Elvesztette a hozzáférést?",
"new_to_formbricks": "Új a Formbicksen?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Visszaszerzési kód használata"
},
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
@@ -319,6 +321,9 @@
"notifications": "Értesítések",
"number": "Szám",
"off": "Ki",
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Elvesztett hozzáférés",
@@ -1236,6 +1246,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_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytató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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
"update_personal_info": "Személyes információk frissítése",
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
"warning_cannot_undo": "Ezt nem lehet visszavonni"
"warning_cannot_undo": "Ezt nem lehet visszavonni",
"wrong_password": "Hibás jelszó"
},
"teams": {
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
@@ -1777,6 +1790,11 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
"validation": {
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
"answer_all_rows": "Válaszoljon az összes sorra",
@@ -2117,6 +2135,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_1": "Részben elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
+22 -3
View File
@@ -63,6 +63,8 @@
"login_with_email": "メールアドレスでログイン",
"lost_access": "アクセスを紛失しましたか?",
"new_to_formbricks": "Formbricksを初めてご利用ですか?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "バックアップコードを使用"
},
"saml_connection_error": "問題が発生しました。詳細については、アプリのコンソールを確認してください。",
@@ -319,6 +321,9 @@
"notifications": "通知",
"number": "数値",
"off": "オフ",
"offline_all_responses_synced": "回答が正常に保存されました。",
"offline_syncing_responses": "回答を同期中…",
"offline_you_are_offline": "オフラインです。回答はブラウザに保存されており、オンラインに戻ると保存されます。",
"on": "オン",
"only_one_file_allowed": "ファイルは1つのみ許可されています",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "回答を直接Notionに送信します",
"please_select_a_survey_error": "フォームを選択してください",
"reconnect_button": "再接続",
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
"slack": {
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
@@ -1220,10 +1228,12 @@
"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": "認証アプリからコードを以下に入力してください。",
"lost_access": "アクセスを紛失しましたか",
@@ -1236,6 +1246,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
"sso_identity_confirmation_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桁のコードを入力してください。",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
"update_personal_info": "個人情報を更新",
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
"warning_cannot_undo": "この操作は元に戻せません"
"warning_cannot_undo": "この操作は元に戻せません",
"wrong_password": "パスワードが間違っています"
},
"teams": {
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
@@ -1777,6 +1790,11 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validate_id_duplicate": "{type} IDは質問、非表示フィールド、または変数に既に存在します。",
"validate_id_empty": "{type} IDを入力してください。",
"validate_id_invalid_chars": "{type} IDは使用できません。英数字、ハイフン、アンダースコアのみを使用してください。",
"validate_id_no_spaces": "{type} IDにスペースを含めることはできません。スペースを削除してください。",
"validate_id_reserved": "{type} ID \"{field}\" は使用できません。予約されたキーワードです。",
"validation": {
"add_validation_rule": "検証ルールを追加",
"answer_all_rows": "すべての行に回答してください",
@@ -2117,6 +2135,7 @@
"this_quarter": "今四半期",
"this_year": "今年",
"time_to_complete": "完了までの時間",
"ttc_survey_tooltip": "アンケートの平均完了時間。",
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
@@ -2627,8 +2646,8 @@
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
"csat_question_1_lower_label": "可能性が低い",
"csat_question_1_upper_label": "可能性が非常に高い",
"csat_question_2_choice_1": "やや満足",
"csat_question_2_choice_2": "非常に満足",
"csat_question_2_choice_1": "やや満足している",
"csat_question_2_choice_2": "とても満足している",
"csat_question_2_choice_3": "満足も不満もない",
"csat_question_2_choice_4": "やや不満",
"csat_question_2_choice_5": "非常に不満",
+24 -5
View File
@@ -63,6 +63,8 @@
"login_with_email": "Inloggen met e-mail",
"lost_access": "Toegang verloren?",
"new_to_formbricks": "Nieuw bij Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Gebruik een back-upcode"
},
"saml_connection_error": "Er is iets misgegaan. Controleer uw app-console voor meer details.",
@@ -254,7 +256,7 @@
"images": "Afbeeldingen",
"import": "Importeren",
"impressions": "Indrukken",
"imprint": "Afdruk",
"imprint": "Wettelijke vermeldingen",
"in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
@@ -319,6 +321,9 @@
"notifications": "Meldingen",
"number": "Nummer",
"off": "Uit",
"offline_all_responses_synced": "Je reactie is succesvol opgeslagen.",
"offline_syncing_responses": "Je antwoorden synchroniseren…",
"offline_you_are_offline": "Je bent offline. Je reactie is opgeslagen in je browser en wordt bewaard zodra je weer online bent.",
"on": "Op",
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
@@ -368,7 +373,7 @@
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
"replace": "Vervangen",
"report_survey": "Verslag enquête",
"report_survey": "Enquête melden",
"request_trial_license": "Proeflicentie aanvragen",
"reset_to_default": "Resetten naar standaard",
"response": "Antwoord",
@@ -512,7 +517,7 @@
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"imprint": "Wettelijke vermeldingen",
"invite_accepted_email_heading": "Hé {inviterName}",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
"please_select_a_survey_error": "Selecteer een enquête",
"reconnect_button": "Opnieuw verbinden",
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
"slack": {
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Toegang verloren",
@@ -1236,6 +1246,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_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
"update_personal_info": "Update uw persoonlijke gegevens",
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
"wrong_password": "Verkeerd wachtwoord"
},
"teams": {
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
@@ -1777,6 +1790,11 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validate_id_duplicate": "{type}-ID bestaat al in vragen, verborgen velden of variabelen.",
"validate_id_empty": "Voer een {type}-ID in.",
"validate_id_invalid_chars": "{type}-ID is niet toegestaan. Gebruik alleen alfanumerieke tekens, koppeltekens of underscores.",
"validate_id_no_spaces": "{type}-ID mag geen spaties bevatten. Verwijder de spaties.",
"validate_id_reserved": "{type}-ID \"{field}\" is niet toegestaan. Dit is een gereserveerd trefwoord.",
"validation": {
"add_validation_rule": "Validatieregel toevoegen",
"answer_all_rows": "Beantwoord alle rijen",
@@ -2117,6 +2135,7 @@
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
"time_to_complete": "Tijd om te voltooien",
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
"unknown_question_type": "Onbekend vraagtype",
"use_personal_links": "Gebruik persoonlijke links",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
"csat_question_1_lower_label": "Niet waarschijnlijk",
"csat_question_1_upper_label": "Zeer waarschijnlijk",
"csat_question_2_choice_1": "Enigszins tevreden",
"csat_question_2_choice_1": "Redelijk tevreden",
"csat_question_2_choice_2": "Zeer tevreden",
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
"csat_question_2_choice_4": "Enigszins ontevreden",
+21 -2
View File
@@ -63,6 +63,8 @@
"login_with_email": "Entrar com Email",
"lost_access": "Perdeu o acesso?",
"new_to_formbricks": "Novo no Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Usar um código de backup"
},
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
@@ -319,6 +321,9 @@
"notifications": "Notificações",
"number": "Número",
"off": "desligado",
"offline_all_responses_synced": "Sua resposta foi salva com sucesso.",
"offline_syncing_responses": "Sincronizando suas respostas…",
"offline_you_are_offline": "Você está offline. Sua resposta está armazenada no seu navegador e será salva quando você voltar a ficar online.",
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Perdi o acesso",
@@ -1236,6 +1246,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_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
"update_personal_info": "Atualize suas informações pessoais",
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
"warning_cannot_undo": "Isso não pode ser desfeito"
"warning_cannot_undo": "Isso não pode ser desfeito",
"wrong_password": "Senha incorreta"
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
@@ -1777,6 +1790,11 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validate_id_duplicate": "O ID de {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, insira um ID de {type}.",
"validate_id_invalid_chars": "O ID de {type} não é permitido. Por favor, use apenas caracteres alfanuméricos, hífens ou underscores.",
"validate_id_no_spaces": "O ID de {type} não pode conter espaços. Por favor, remova os espaços.",
"validate_id_reserved": "O ID de {type} \"{field}\" não é permitido. É uma palavra reservada.",
"validation": {
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda todas as linhas",
@@ -2117,6 +2135,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
"csat_question_1_lower_label": "Pouco provável",
"csat_question_1_upper_label": "Muito provável",
"csat_question_2_choice_1": "Parcialmente satisfeito",
"csat_question_2_choice_1": "Um pouco satisfeito",
"csat_question_2_choice_2": "Muito satisfeito",
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
"csat_question_2_choice_4": "Um pouco insatisfeito",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "Iniciar sessão com Email",
"lost_access": "Perdeu o acesso?",
"new_to_formbricks": "Novo no Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Use um código de backup"
},
"saml_connection_error": "Algo correu mal. Por favor, verifique a consola da aplicação para mais detalhes.",
@@ -319,6 +321,9 @@
"notifications": "Notificações",
"number": "Número",
"off": "Desligado",
"offline_all_responses_synced": "A tua resposta foi guardada com sucesso.",
"offline_syncing_responses": "A sincronizar as tuas respostas…",
"offline_you_are_offline": "Estás offline. A tua resposta está armazenada no teu navegador e será guardada quando voltares a estar online.",
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"reconnect_button": "Voltar a ligar",
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Perdeu o acesso",
@@ -1236,6 +1246,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_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
"update_personal_info": "Atualize as suas informações pessoais",
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
"warning_cannot_undo": "Isto não pode ser desfeito"
"warning_cannot_undo": "Isto não pode ser desfeito",
"wrong_password": "Palavra-passe incorreta"
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
@@ -1777,6 +1790,11 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validate_id_duplicate": "O ID {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, introduza um ID {type}.",
"validate_id_invalid_chars": "O ID {type} não é permitido. Por favor, utilize apenas caracteres alfanuméricos, hífenes ou underscores.",
"validate_id_no_spaces": "O ID {type} não pode conter espaços. Por favor, remova os espaços.",
"validate_id_reserved": "O ID {type} \"{field}\" não é permitido. É uma palavra reservada.",
"validation": {
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda a todas as linhas",
@@ -2117,6 +2135,7 @@
"this_quarter": "Este trimestre",
"this_year": "Este ano",
"time_to_complete": "Tempo para Concluir",
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
+21 -2
View File
@@ -63,6 +63,8 @@
"login_with_email": "Autentifică-te cu email",
"lost_access": "Acces pierdut?",
"new_to_formbricks": "Nou în Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Folosiți un cod de rezervă"
},
"saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
@@ -319,6 +321,9 @@
"notifications": "Notificări",
"number": "Număr",
"off": "Oprit",
"offline_all_responses_synced": "Răspunsul tău a fost salvat cu succes.",
"offline_syncing_responses": "Se sincronizează răspunsurile tale…",
"offline_you_are_offline": "Ești offline. Răspunsul tău este stocat în browser și va fi salvat când vei fi din nou online.",
"on": "Pe",
"only_one_file_allowed": "Este permis doar un fișier",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Trimiteți datele în baza de date Notion",
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
"slack": {
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Acces pierdut",
@@ -1236,6 +1246,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_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
"warning_cannot_undo": "Aceasta nu poate fi anulată"
"warning_cannot_undo": "Aceasta nu poate fi anulată",
"wrong_password": "Parolă greșită"
},
"teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
@@ -1777,6 +1790,11 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validate_id_duplicate": "ID-ul {type} există deja în întrebări, câmpuri ascunse sau variabile.",
"validate_id_empty": "Te rugăm să introduci un ID {type}.",
"validate_id_invalid_chars": "ID-ul {type} nu este permis. Te rugăm să folosești doar caractere alfanumerice, cratimă sau liniuță de subliniere.",
"validate_id_no_spaces": "ID-ul {type} nu poate conține spații. Te rugăm să elimini spațiile.",
"validate_id_reserved": "ID-ul {type} \"{field}\" nu este permis. Este un cuvânt cheie rezervat.",
"validation": {
"add_validation_rule": "Adaugă regulă de validare",
"answer_all_rows": "Răspunde la toate rândurile",
@@ -2117,6 +2135,7 @@
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
"time_to_complete": "Timp de finalizare",
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
"csat_question_1_lower_label": "Puțin probabil",
"csat_question_1_upper_label": "Foarte probabil",
"csat_question_2_choice_1": "Destul de mulțumit",
"csat_question_2_choice_1": "Oarecum mulțumit",
"csat_question_2_choice_2": "Foarte mulțumit",
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
"csat_question_2_choice_4": "Ușor nemulțumit",
+21 -2
View File
@@ -63,6 +63,8 @@
"login_with_email": "Войти по email",
"lost_access": "Потеряли доступ?",
"new_to_formbricks": "Впервые на Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Использовать резервный код"
},
"saml_connection_error": "Что-то пошло не так. Пожалуйста, проверьте консоль приложения для получения подробностей.",
@@ -319,6 +321,9 @@
"notifications": "Уведомления",
"number": "Номер",
"off": "Выкл.",
"offline_all_responses_synced": "Твой ответ успешно сохранён.",
"offline_syncing_responses": "Синхронизируем твои ответы…",
"offline_you_are_offline": "Ты не в сети. Твой ответ сохранён в браузере и будет отправлен, когда подключение восстановится.",
"on": "Вкл.",
"only_one_file_allowed": "Разрешён только один файл",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
"slack": {
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
@@ -1220,10 +1228,12 @@
"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": "Введите ниже код из вашего приложения-аутентификатора.",
"lost_access": "Потерян доступ",
@@ -1236,6 +1246,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_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": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
"update_personal_info": "Обновить личную информацию",
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
"warning_cannot_undo": "Это действие необратимо"
"warning_cannot_undo": "Это действие необратимо",
"wrong_password": "Неверный пароль"
},
"teams": {
"add_members_description": "Добавьте участников в команду и определите их роль.",
@@ -1777,6 +1790,11 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validate_id_duplicate": "ID {type} уже существует в вопросах, скрытых полях или переменных.",
"validate_id_empty": "Пожалуйста, введите ID {type}.",
"validate_id_invalid_chars": "ID {type} недопустим. Пожалуйста, используйте только буквенно-цифровые символы, дефисы или подчеркивания.",
"validate_id_no_spaces": "ID {type} не может содержать пробелы. Пожалуйста, удалите пробелы.",
"validate_id_reserved": "ID {type} \"{field}\" недопустим. Это зарезервированное ключевое слово.",
"validation": {
"add_validation_rule": "Добавить правило проверки",
"answer_all_rows": "Ответьте на все строки",
@@ -2117,6 +2135,7 @@
"this_quarter": "В этом квартале",
"this_year": "В этом году",
"time_to_complete": "Время на прохождение",
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
"ttc_tooltip": "Среднее время на ответ на вопрос.",
"unknown_question_type": "Неизвестный тип вопроса",
"use_personal_links": "Использовать персональные ссылки",
@@ -2627,7 +2646,7 @@
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
"csat_question_1_lower_label": "Маловероятно",
"csat_question_1_upper_label": "Очень вероятно",
"csat_question_2_choice_1": "В целом доволен",
"csat_question_2_choice_1": "Скорее доволен",
"csat_question_2_choice_2": "Очень доволен",
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
"csat_question_2_choice_4": "В целом недоволен",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "Logga in med e-post",
"lost_access": "Förlorad åtkomst?",
"new_to_formbricks": "Ny på Formbricks?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "Använd en reservkod"
},
"saml_connection_error": "Något gick fel. Kontrollera din appkonsol för mer information.",
@@ -319,6 +321,9 @@
"notifications": "Aviseringar",
"number": "Nummer",
"off": "Av",
"offline_all_responses_synced": "Ditt svar har sparats.",
"offline_syncing_responses": "Synkroniserar dina svar…",
"offline_you_are_offline": "Du är offline. Ditt svar är lagrat i din webbläsare och kommer att sparas när du är online igen.",
"on": "På",
"only_one_file_allowed": "Endast en fil är tillåten",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "Skicka data till din Notion-databas",
"please_select_a_survey_error": "Vänligen välj en enkät",
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
"slack": {
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
@@ -1220,10 +1228,12 @@
"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.",
"lost_access": "Förlorad åtkomst",
@@ -1236,6 +1246,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_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen 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.",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
"update_personal_info": "Uppdatera din personliga information",
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
"warning_cannot_undo": "Detta kan inte ångras"
"warning_cannot_undo": "Detta kan inte ångras",
"wrong_password": "Fel lösenord"
},
"teams": {
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
@@ -1777,6 +1790,11 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validate_id_duplicate": "{type}-ID finns redan i frågor, dolda fält eller variabler.",
"validate_id_empty": "Vänligen ange ett {type}-ID.",
"validate_id_invalid_chars": "{type}-ID är inte tillåtet. Använd endast alfanumeriska tecken, bindestreck eller understreck.",
"validate_id_no_spaces": "{type}-ID kan inte innehålla mellanslag. Vänligen ta bort mellanslag.",
"validate_id_reserved": "{type}-ID \"{field}\" är inte tillåtet. Det är ett reserverat nyckelord.",
"validation": {
"add_validation_rule": "Lägg till valideringsregel",
"answer_all_rows": "Svara på alla rader",
@@ -2117,6 +2135,7 @@
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
"time_to_complete": "Tid att slutföra",
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
"unknown_question_type": "Okänd frågetyp",
"use_personal_links": "Använd personliga länkar",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "使用 邮箱 登录",
"lost_access": "失去访问?",
"new_to_formbricks": "新用户 Formbricks ?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "使用 备用代码"
},
"saml_connection_error": "出了问题。请查看应用 控制台 以获取更多详情。",
@@ -319,6 +321,9 @@
"notifications": "通知",
"number": "数字",
"off": "关闭",
"offline_all_responses_synced": "您的回复已成功保存。",
"offline_syncing_responses": "正在同步您的回复…",
"offline_you_are_offline": "您当前处于离线状态。您的回复已存储在浏览器中,将在您重新联网后保存。",
"on": "开启",
"only_one_file_allowed": "只 允许 一个 文件",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
"please_select_a_survey_error": "请选择 一个 调查",
"reconnect_button": "重新连接",
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
"select_at_least_one_question_error": "请选择至少 一个问题",
"slack": {
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
@@ -1220,10 +1228,12 @@
"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": "从 你的 身份验证 应用 中 输入 代码 。",
"lost_access": "失去访问",
@@ -1236,6 +1246,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除你的账户。",
"sso_identity_confirmation_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": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
"update_personal_info": "更新你的个人信息",
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
"warning_cannot_undo": "此 无法 撤销。"
"warning_cannot_undo": "此 无法 撤销。",
"wrong_password": "密码错误"
},
"teams": {
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
@@ -1777,6 +1790,11 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validate_id_duplicate": "{type} ID 已存在于问题、隐藏字段或变量中。",
"validate_id_empty": "请输入 {type} ID。",
"validate_id_invalid_chars": "{type} ID 不允许使用。请仅使用字母数字字符、连字符或下划线。",
"validate_id_no_spaces": "{type} ID 不能包含空格。请删除空格。",
"validate_id_reserved": "{type} ID \"{field}\" 不允许使用。它是保留关键字。",
"validation": {
"add_validation_rule": "添加验证规则",
"answer_all_rows": "请填写所有行",
@@ -2117,6 +2135,7 @@
"this_quarter": "本季度",
"this_year": "今年",
"time_to_complete": "完成时间",
"ttc_survey_tooltip": "完成调查的平均时间。",
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
+20 -1
View File
@@ -63,6 +63,8 @@
"login_with_email": "使用電子郵件登入",
"lost_access": "無法存取?",
"new_to_formbricks": "初次使用 Formbricks",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"use_a_backup_code": "使用備份碼"
},
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
@@ -319,6 +321,9 @@
"notifications": "通知",
"number": "數字",
"off": "關閉",
"offline_all_responses_synced": "你的回應已成功儲存。",
"offline_syncing_responses": "正在同步你的回應…",
"offline_you_are_offline": "你目前離線。你的回應已暫存在瀏覽器中,並將在恢復網路連線後自動儲存。",
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
@@ -823,6 +828,9 @@
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"please_select_a_survey_error": "請選取問卷",
"reconnect_button": "重新連接",
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
@@ -1220,10 +1228,12 @@
"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": "在下方輸入您驗證器應用程式中的程式碼。",
"lost_access": "無法存取",
@@ -1236,6 +1246,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除你的帳戶。",
"sso_identity_confirmation_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": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -1243,7 +1255,8 @@
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原"
"warning_cannot_undo": "此操作無法復原",
"wrong_password": "密碼錯誤"
},
"teams": {
"add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1777,6 +1790,11 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validate_id_duplicate": "{type} ID 已存在於問題、隱藏欄位或變數中。",
"validate_id_empty": "請輸入 {type} ID。",
"validate_id_invalid_chars": "不允許使用此 {type} ID。請僅使用英數字元、連字號或底線。",
"validate_id_no_spaces": "{type} ID 不能包含空格。請移除空格。",
"validate_id_reserved": "不允許使用 {type} ID「{field}」。這是保留關鍵字。",
"validation": {
"add_validation_rule": "新增驗證規則",
"answer_all_rows": "請填答所有列",
@@ -2117,6 +2135,7 @@
"this_quarter": "本季",
"this_year": "今年",
"time_to_complete": "完成時間",
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
@@ -1,24 +1,87 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser, getUser } from "@/lib/user/service";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
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";
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging("deleted", "user", async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
password: z.string().max(128).optional(),
returnToUrl: z.string().trim().max(2048).pipe(z.url()).optional(),
})
);
.strict();
const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
const isSsoConfirmationRequiredError = (error: unknown) =>
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
export const deleteUserAction = authenticatedActionClient
.inputSchema(ZDeleteUserConfirmation)
.action(async ({ ctx, parsedInput }) => {
const userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
const { confirmationEmail, password } = parsedInput;
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail,
password,
userEmail: ctx.user.email,
userId,
});
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
capturePostHogEvent(userId, "delete_account");
return { success: true };
} catch (error) {
if (isSsoConfirmationRequiredError(error)) {
const { confirmationEmail, returnToUrl } = parsedInput;
try {
return {
ssoConfirmation: await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl: returnToUrl ?? WEBAPP_URL,
userId,
}),
};
} catch (ssoConfirmationError) {
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logger.error(
{ error: ssoConfirmationError, userId },
"Account deletion SSO identity confirmation failed"
);
throw ssoConfirmationError;
}
}
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logAccountDeletionError(userId, error);
throw error;
}
});
@@ -1,16 +1,28 @@
"use client";
import { signIn } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_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 { 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";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
user: TUser;
@@ -19,55 +31,120 @@ 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 { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [password, setPassword] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setInputValue("");
setPassword("");
}
setOpen(nextOpen);
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation =
hasValidEmailConfirmation && (!requiresPasswordConfirmation || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const getLocalizedDeletionErrorMessage = (serverError?: string) => {
if (serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
return t("environments.settings.profile.wrong_password");
}
if (serverError === ACCOUNT_DELETION_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_identity_confirmation_failed");
}
return null;
};
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
return;
}
setDeleting(true);
await deleteUserAction();
const result = await deleteUserAction(
requiresPasswordConfirmation
? {
confirmationEmail: inputValue,
password,
returnToUrl: globalThis.location.href,
}
: {
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
}
);
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
clearEnvironmentId: true,
});
if (result?.data?.ssoConfirmation) {
await signIn(
result.data.ssoConfirmation.provider,
{
callbackUrl: result.data.ssoConfirmation.callbackUrl,
redirect: true,
},
result.data.ssoConfirmation.authorizationParams
);
return;
}
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage = result
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
: fallbackErrorMessage;
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
globalThis.localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Manual redirect after signOut completes
if (isFormbricksCloud) {
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
window.location.replace("/auth/login");
globalThis.location.replace("/auth/login");
}
} catch (error) {
toast.error("Something went wrong");
logger.error({ error }, "Account deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
setOpen={handleOpenChange}
deleteWhat={t("common.account")}
onDelete={() => deleteAccount()}
text={t("environments.settings.profile.account_deletion_consequences_warning")}
isDeleting={deleting}
disabled={inputValue !== user.email}>
disabled={isDeleteDisabled}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
@@ -110,11 +187,34 @@ export const DeleteAccountModal = ({
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}
className="mt-5"
className="mt-2"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("environments.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
</label>
<PasswordInput
data-testid="deleteAccountPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
containerClassName="mt-2"
id="deleteAccountPassword"
name="deleteAccountPassword"
required
/>
</>
)}
</form>
</div>
</DeleteDialog>
+10
View File
@@ -0,0 +1,10 @@
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 FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -0,0 +1,34 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const queueAccountDeletionAuditEvent = async ({
eventId,
oldUser,
status,
targetUserId,
userId = targetUserId,
}: {
eventId?: string;
oldUser?: Record<string, unknown> | null;
status: "success" | "failure";
targetUserId: string;
userId?: string;
}) => {
try {
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId,
userType: "user",
targetId: targetUserId,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status,
...(eventId ? { eventId } : {}),
});
} catch (error) {
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
}
};
@@ -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,542 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
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 { 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_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;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
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 INTERACTIVE_SSO_CONFIRMATION_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
"google",
"openid",
"saml",
]);
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 getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
// This flow asks supported providers for an interactive login, but still only treats the callback
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
// validation here unless the product decision changes back to strict step-up authentication.
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
if (provider === "saml") {
return {
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { forceAuthn: "true" } : {}),
product: SAML_PRODUCT,
provider: "saml",
tenant: SAML_TENANT,
};
}
if (provider === "github") {
return {
login: email,
prompt: "select_account",
};
}
return {
login_hint: email,
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { prompt: "login" } : {}),
};
};
const getAccountDeletionSsoReauthErrorCode = () => ACCOUNT_DELETION_SSO_REAUTH_FAILED_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();
};
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 = ({
intentToken,
}: {
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()
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation 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 identity confirmation intent"
);
throw new Error("Unable to start account deletion SSO identity confirmation");
}
};
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 identity confirmation marker"
);
throw new Error("Unable to complete account deletion SSO identity confirmation");
}
};
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 identity confirmation cache"
);
throw error;
}
if (!redis) {
logger.error(
{ ...logContext, key },
"Redis is required to atomically consume SSO identity confirmation cache value"
);
throw new Error("Unable to consume account deletion SSO identity confirmation 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 identity confirmation value"
);
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error(
{ ...logContext, error, key },
"Failed to atomically consume SSO identity confirmation 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 identity confirmation cache value"
);
throw new Error("Unable to read account deletion SSO identity confirmation 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 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);
}
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,
});
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
);
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
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 identity confirmation"
);
};
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
);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
{ userId }
);
if (
marker?.userId !== userId ||
marker?.provider !== provider ||
marker?.providerAccountId !== ssoProviderAccountId ||
Date.now() - (marker?.completedAt ?? 0) > ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS
) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
@@ -0,0 +1,108 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } 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 canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
const assertAccountDeletionSsoIdentityConfirmation = async ({
identityProvider,
providerAccountId,
userId,
}: {
identityProvider: IdentityProvider;
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoIdentityConfirmation(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO identity confirmation 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 assertAccountDeletionSsoIdentityConfirmation({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
});
}
await deleteUser(userId);
return { oldUser };
};
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
/>
);
} else if (Array.isArray(responseData)) {
const itemsArray = responseData.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
const itemsArray = responseData
.filter((choice) => choice !== "")
.map((choice) => {
const choiceId = getChoiceIdByValue(choice, element);
return { value: choice, id: choiceId };
});
return (
<>
{element.type === TSurveyElementTypeEnum.Ranking ? (
@@ -1,5 +1,7 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { createUser, getUsers, updateUser } from "../users";
@@ -11,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -58,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -82,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -93,6 +107,51 @@ describe("Users Lib", () => {
expect(result.error.type).toBe("internal_server_error");
}
});
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
"org456"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("conflict");
expect(result.error.details).toEqual([
{ field: "email", issue: "A user with this email already exists" },
]);
}
});
test("returns internal_server_error if unique constraint violation is not on email", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`organizationId`,`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["organizationId"] },
}
)
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
"org456"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateUser", () => {
@@ -104,6 +163,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -1,5 +1,6 @@
import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TUser } from "@formbricks/database/zod/users";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
@@ -142,6 +143,20 @@ export const createUser = async (
return ok(returnedUser);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const target = error.meta?.target as string[] | undefined;
if (target?.includes("email")) {
return err({
type: "conflict",
details: [{ field: "email", issue: "A user with this email already exists" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "user", issue: error instanceof Error ? error.message : "Unknown error occurred" }],
+71 -15
View File
@@ -158,7 +158,7 @@ describe("authOptions", () => {
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -166,10 +166,10 @@ describe("authOptions", () => {
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
}, 15000);
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
@@ -181,10 +181,10 @@ describe("authOptions", () => {
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
);
});
}, 15000);
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -196,10 +196,10 @@ describe("authOptions", () => {
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
}, 15000);
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
const fakeUser = {
id: mockUserId,
email: mockUser.email,
@@ -218,11 +218,11 @@ describe("authOptions", () => {
email: fakeUser.email,
emailVerified: fakeUser.emailVerified,
});
});
}, 15000);
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -255,7 +255,7 @@ describe("authOptions", () => {
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -278,7 +278,7 @@ describe("authOptions", () => {
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -307,7 +307,7 @@ describe("authOptions", () => {
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
@@ -317,7 +317,7 @@ describe("authOptions", () => {
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const credentials = { token: "sometoken" };
@@ -435,11 +435,67 @@ describe("authOptions", () => {
});
});
describe("Enterprise SSO signIn callback", () => {
test("should not update lastLoginAt when SSO sign-in is denied", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockRejectedValueOnce(new Error("OAuthAccountNotLinked"));
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).rejects.toThrow(
"OAuthAccountNotLinked"
);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
expect(mockCapturePostHogEvent).not.toHaveBeenCalled();
});
});
describe("Two-Factor Authentication (TOTP)", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -457,7 +513,7 @@ describe("authOptions", () => {
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
+47 -12
View File
@@ -12,10 +12,19 @@ import {
ENTERPRISE_LICENSE_KEY,
POSTHOG_KEY,
SESSION_MAX_AGE,
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { capturePostHogEvent } from "@/lib/posthog";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
completeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback,
} from "@/modules/account/lib/account-deletion-sso-reauth";
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
@@ -332,9 +341,9 @@ export const authOptions: NextAuthOptions = {
// get callback url from the cookie store,
const callbackUrl =
cookieStore.get("__Secure-next-auth.callback-url")?.value ||
cookieStore.get("next-auth.callback-url")?.value ||
"";
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
const accountDeletionSsoReauthIntentToken =
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
const userEmail = user.email ?? "";
const userId = user.id as string;
@@ -372,17 +381,43 @@ export const authOptions: NextAuthOptions = {
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
try {
if (accountDeletionSsoReauthIntentToken) {
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken: accountDeletionSsoReauthIntentToken,
});
}
if (result) {
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
if (result) {
if (accountDeletionSsoReauthIntentToken) {
await completeAccountDeletionSsoReauthentication({
account,
intentToken: accountDeletionSsoReauthIntentToken,
});
}
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
}
return result;
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
intentToken: accountDeletionSsoReauthIntentToken,
});
if (failureRedirectUrl) {
return failureRedirectUrl;
}
throw error;
}
return result;
}
void captureSignIn(account?.provider ?? "unknown");
await updateUserLastLoginAt(userEmail);
@@ -0,0 +1,84 @@
import { describe, expect, test } from "vitest";
import {
getAuthCallbackUrlFromCookies,
getInviteTokenFromCallbackUrl,
getRelativeCallbackUrl,
resolveAuthCallbackUrl,
} from "./callback-url";
const WEBAPP_URL = "http://localhost:3000";
describe("auth callback URL helpers", () => {
test("prefers a valid callback URL from search params over the cookie", () => {
const result = resolveAuthCallbackUrl({
searchParamCallbackUrl: "http://localhost:3000/invite?token=search-token",
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token",
webAppUrl: WEBAPP_URL,
});
expect(result).toBe("http://localhost:3000/invite?token=search-token");
});
test("falls back to the callback URL cookie when the query string only contains an auth error", () => {
const result = resolveAuthCallbackUrl({
searchParamCallbackUrl: undefined,
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token&source=signin",
allowCookieFallback: true,
webAppUrl: WEBAPP_URL,
});
expect(result).toBe("http://localhost:3000/invite?token=cookie-token&source=signin");
});
test("does not fall back to the callback URL cookie unless explicitly allowed", () => {
const result = resolveAuthCallbackUrl({
searchParamCallbackUrl: undefined,
cookieCallbackUrl: "http://localhost:3000/invite?token=cookie-token&source=signin",
webAppUrl: WEBAPP_URL,
});
expect(result).toBeNull();
});
test("rejects callback URLs on a different origin", () => {
const result = resolveAuthCallbackUrl({
searchParamCallbackUrl: "https://evil.example/invite?token=bad",
cookieCallbackUrl: "https://evil.example/fallback",
webAppUrl: WEBAPP_URL,
});
expect(result).toBeNull();
});
test("returns a relative callback path for in-app navigation", () => {
const result = getRelativeCallbackUrl(
"http://localhost:3000/invite?token=abc123&source=signin",
WEBAPP_URL
);
expect(result).toBe("/invite?token=abc123&source=signin");
});
test("extracts invite tokens from validated callback URLs", () => {
const result = getInviteTokenFromCallbackUrl(
"http://localhost:3000/invite?token=abc123&source=signin",
WEBAPP_URL
);
expect(result).toBe("abc123");
});
test("reads the Auth.js callback URL from the secure cookie first", () => {
const result = getAuthCallbackUrlFromCookies({
get: (name: string) => {
if (name === "__Secure-next-auth.callback-url") {
return { value: "http://localhost:3000/invite?token=secure" };
}
return undefined;
},
});
expect(result).toBe("http://localhost:3000/invite?token=secure");
});
});
+83
View File
@@ -0,0 +1,83 @@
import { getValidatedCallbackUrl } from "@/lib/utils/url";
export const AUTH_CALLBACK_URL_COOKIE_NAMES = [
"__Secure-next-auth.callback-url",
"next-auth.callback-url",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
const getSearchParamValue = (value?: string | string[]): string | null => {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
};
export const getAuthCallbackUrlFromCookies = (cookieStore: TCookieStore): string | null => {
for (const cookieName of AUTH_CALLBACK_URL_COOKIE_NAMES) {
const callbackUrl = cookieStore.get(cookieName)?.value;
if (callbackUrl) {
return callbackUrl;
}
}
return null;
};
export const resolveAuthCallbackUrl = ({
searchParamCallbackUrl,
cookieCallbackUrl,
allowCookieFallback = false,
webAppUrl,
}: {
searchParamCallbackUrl?: string | string[];
cookieCallbackUrl?: string | null;
allowCookieFallback?: boolean;
webAppUrl: string;
}): string | null => {
const callbackUrlFromSearchParams = getSearchParamValue(searchParamCallbackUrl);
const validatedSearchParamCallbackUrl = getValidatedCallbackUrl(callbackUrlFromSearchParams, webAppUrl);
if (validatedSearchParamCallbackUrl) {
return validatedSearchParamCallbackUrl;
}
if (!allowCookieFallback) {
return null;
}
return getValidatedCallbackUrl(cookieCallbackUrl, webAppUrl);
};
export const getRelativeCallbackUrl = (callbackUrl: string | null | undefined, webAppUrl: string): string => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl);
if (!validatedCallbackUrl) {
return "/";
}
const parsedCallbackUrl = new URL(validatedCallbackUrl);
const relativeCallbackUrl = `${parsedCallbackUrl.pathname}${parsedCallbackUrl.search}${parsedCallbackUrl.hash}`;
return relativeCallbackUrl || "/";
};
export const getInviteTokenFromCallbackUrl = (
callbackUrl: string | null | undefined,
webAppUrl: string
): string | null => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl);
if (!validatedCallbackUrl) {
return null;
}
return new URL(validatedCallbackUrl).searchParams.get("token");
};
export const getSearchParamString = (value?: string | string[]): string => getSearchParamValue(value) ?? "";
+9 -5
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -7,11 +7,15 @@ import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbr
import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "@/lib/utils/validate";
export const updateUser = async (id: string, data: TUserUpdateInput) => {
type TUserDbClient = PrismaClient | Prisma.TransactionClient;
const getDbClient = (tx?: Prisma.TransactionClient): TUserDbClient => tx ?? prisma;
export const updateUser = async (id: string, data: TUserUpdateInput, tx?: Prisma.TransactionClient) => {
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
try {
const updatedUser = await prisma.user.update({
const updatedUser = await getDbClient(tx).user.update({
where: {
id,
},
@@ -113,10 +117,10 @@ export const getUser = reactCache(async (id: string) => {
}
});
export const createUser = async (data: TUserCreateInput) => {
export const createUser = async (data: TUserCreateInput, tx?: Prisma.TransactionClient) => {
validateInputs([data, ZUserUpdateInput]);
try {
const user = await prisma.user.create({
const user = await getDbClient(tx).user.create({
data: data,
select: {
name: true,
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { buildVerificationLinks, buildVerificationRequestedPath } from "./verification-links";
const WEBAPP_URL = "http://localhost:3000";
describe("verification link helpers", () => {
test("builds a verification requested path with token only", () => {
expect(buildVerificationRequestedPath({ token: "abc123" })).toBe(
"/auth/verification-requested?token=abc123"
);
});
test("builds a verification requested path that preserves callback URL", () => {
expect(
buildVerificationRequestedPath({
token: "abc123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
})
).toBe(
"/auth/verification-requested?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token"
);
});
test("builds absolute verification links that preserve a valid callback URL", () => {
expect(
buildVerificationLinks({
token: "abc123",
webAppUrl: WEBAPP_URL,
callbackUrl: "http://localhost:3000/environments/test?foo=bar",
})
).toEqual({
verificationRequestLink:
"http://localhost:3000/auth/verification-requested?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar",
verifyLink:
"http://localhost:3000/auth/verify?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar",
});
});
test("drops invalid callback URLs from absolute verification links", () => {
expect(
buildVerificationLinks({
token: "abc123",
webAppUrl: WEBAPP_URL,
callbackUrl: "https://evil.example/phish",
})
).toEqual({
verificationRequestLink: "http://localhost:3000/auth/verification-requested?token=abc123",
verifyLink: "http://localhost:3000/auth/verify?token=abc123",
});
});
});
@@ -0,0 +1,47 @@
import { getValidatedCallbackUrl } from "@/lib/utils/url";
const RELATIVE_URL_BASE = "http://localhost";
export const buildVerificationRequestedPath = ({
token,
callbackUrl,
}: {
token: string;
callbackUrl?: string | null;
}): string => {
const verificationRequestedUrl = new URL("/auth/verification-requested", RELATIVE_URL_BASE);
verificationRequestedUrl.searchParams.set("token", token);
if (callbackUrl) {
verificationRequestedUrl.searchParams.set("callbackUrl", callbackUrl);
}
return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`;
};
export const buildVerificationLinks = ({
token,
webAppUrl,
callbackUrl,
}: {
token: string;
webAppUrl: string;
callbackUrl?: string | null;
}): { verificationRequestLink: string; verifyLink: string } => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl);
const verifyLink = new URL("/auth/verify", webAppUrl);
verifyLink.searchParams.set("token", token);
const verificationRequestLink = new URL("/auth/verification-requested", webAppUrl);
verificationRequestLink.searchParams.set("token", token);
if (validatedCallbackUrl) {
verifyLink.searchParams.set("callbackUrl", validatedCallbackUrl);
verificationRequestLink.searchParams.set("callbackUrl", validatedCallbackUrl);
}
return {
verificationRequestLink: verificationRequestLink.toString(),
verifyLink: verifyLink.toString(),
};
};
@@ -2,8 +2,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
@@ -13,9 +13,11 @@ import { cn } from "@/lib/cn";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createEmailTokenAction } from "@/modules/auth/actions";
import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { TwoFactor } from "@/modules/ee/two-factor-auth/components/two-factor";
import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-factor-backup";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { PasswordInput } from "@/modules/ui/components/password-input";
@@ -50,6 +52,11 @@ interface LoginFormProps {
samlSsoEnabled: boolean;
samlTenant: string;
samlProduct: string;
oauthError?: string;
prefilledEmail?: string;
inviteToken?: string | null;
resolvedCallbackPath: string;
resolvedCallbackUrl: string;
}
export const LoginForm = ({
@@ -66,16 +73,20 @@ export const LoginForm = ({
samlSsoEnabled,
samlTenant,
samlProduct,
oauthError,
prefilledEmail,
inviteToken,
resolvedCallbackPath,
resolvedCallbackUrl,
}: LoginFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const emailRef = useRef<HTMLInputElement>(null);
const callbackUrl = searchParams?.get("callbackUrl") ?? "";
const oauthAccountNotLinked = oauthError === "OAuthAccountNotLinked";
const { t } = useTranslation();
const form = useForm<TLoginForm>({
defaultValues: {
email: searchParams?.get("email") ?? "",
email: prefilledEmail ?? "",
password: "",
totpCode: "",
backupCode: "",
@@ -89,7 +100,7 @@ export const LoginForm = ({
}
try {
const signInResponse = await signIn("credentials", {
callbackUrl: callbackUrl ?? "/",
callbackUrl: resolvedCallbackUrl || "/",
email: data.email.toLowerCase(),
password: data.password,
...(totpLogin && { totpCode: data.totpCode }),
@@ -105,7 +116,12 @@ export const LoginForm = ({
if (signInResponse?.error === "Email Verification is Pending") {
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
if (emailTokenActionResponse?.data) {
router.push(`/auth/verification-requested?token=${emailTokenActionResponse?.data}`);
router.push(
buildVerificationRequestedPath({
token: emailTokenActionResponse.data,
callbackUrl: resolvedCallbackUrl,
})
);
} else {
const errorMessage = getFormattedErrorMessage(emailTokenActionResponse);
toast.error(errorMessage);
@@ -119,7 +135,7 @@ export const LoginForm = ({
}
if (!signInResponse?.error) {
router.push(searchParams?.get("callbackUrl") ?? "/");
router.push(resolvedCallbackPath || "/");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
@@ -130,7 +146,6 @@ export const LoginForm = ({
const [totpLogin, setTotpLogin] = useState(false);
const [totpBackup, setTotpBackup] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const inviteToken = callbackUrl ? new URL(callbackUrl).searchParams.get("token") : null;
const [lastLoggedInWith, setLastLoggedInWith] = useState("");
useEffect(() => {
@@ -167,9 +182,17 @@ export const LoginForm = ({
<FormProvider {...form}>
<div className="text-center">
<h1 className="mb-4 text-slate-700">{formLabel}</h1>
{oauthAccountNotLinked && (
<Alert variant="error" className="mb-4 text-left">
<AlertTitle>{t("auth.login.oauth_account_not_linked_title")}</AlertTitle>
<AlertDescription>
<p>{t("auth.login.oauth_account_not_linked_description")}</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
{TwoFactorComponent}
{showLogin && (
<div className={cn(totpLogin && "hidden", "space-y-2")}>
@@ -182,6 +205,7 @@ export const LoginForm = ({
<div>
<input
id="email"
ref={emailRef}
type="email"
autoComplete="email"
required
@@ -234,6 +258,7 @@ export const LoginForm = ({
)}
{emailAuthEnabled && (
<Button
type="button"
onClick={() => {
if (!showLogin) {
setShowLogin(true);
@@ -262,7 +287,7 @@ export const LoginForm = ({
samlSsoEnabled={samlSsoEnabled}
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
returnToUrl={resolvedCallbackUrl}
source="signin"
/>
)}
+33 -2
View File
@@ -1,4 +1,5 @@
import { Metadata } from "next";
import { cookies } from "next/headers";
import {
AZURE_OAUTH_ENABLED,
EMAIL_AUTH_ENABLED,
@@ -11,8 +12,16 @@ import {
SAML_PRODUCT,
SAML_TENANT,
SIGNUP_ENABLED,
WEBAPP_URL,
} from "@/lib/constants";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import {
getAuthCallbackUrlFromCookies,
getInviteTokenFromCallbackUrl,
getRelativeCallbackUrl,
getSearchParamString,
resolveAuthCallbackUrl,
} from "@/modules/auth/lib/callback-url";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
@@ -25,14 +34,31 @@ export const metadata: Metadata = {
description: "Open-source Experience Management. Free & open source.",
};
export const LoginPage = async () => {
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
export const LoginPage = async ({
searchParams: searchParamsProps,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) => {
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled, searchParams, cookieStore] = await Promise.all([
getIsMultiOrgEnabled(),
getIsSsoEnabled(),
getIsSamlSsoEnabled(),
searchParamsProps,
cookies(),
]);
const oauthError = getSearchParamString(searchParams.error);
const resolvedCallbackUrl =
resolveAuthCallbackUrl({
searchParamCallbackUrl: searchParams.callbackUrl,
cookieCallbackUrl: getAuthCallbackUrlFromCookies(cookieStore),
allowCookieFallback: oauthError === "OAuthAccountNotLinked",
webAppUrl: WEBAPP_URL,
}) ?? WEBAPP_URL;
const resolvedCallbackPath = getRelativeCallbackUrl(resolvedCallbackUrl, WEBAPP_URL);
const inviteToken = getInviteTokenFromCallbackUrl(resolvedCallbackUrl, WEBAPP_URL);
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
return (
<div className="flex min-h-screen w-full items-center justify-center bg-[#D9F6F4]">
<FormWrapper>
@@ -50,6 +76,11 @@ export const LoginPage = async () => {
samlSsoEnabled={samlSsoEnabled}
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
oauthError={oauthError}
prefilledEmail={getSearchParamString(searchParams.email)}
inviteToken={inviteToken}
resolvedCallbackPath={resolvedCallbackPath}
resolvedCallbackUrl={resolvedCallbackUrl}
/>
</FormWrapper>
</div>
+25 -2
View File
@@ -5,7 +5,12 @@ import { logger } from "@formbricks/logger";
import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import { IS_FORMBRICKS_CLOUD, IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants";
import {
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
TURNSTILE_SECRET_KEY,
WEBAPP_URL,
} from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
@@ -161,6 +166,11 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -186,7 +196,20 @@ async function handlePostUserCreation(
}
if (!emailVerificationDisabled) {
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
let inviteCallbackUrl: string | undefined;
if (inviteToken) {
const inviteUrl = new URL("/invite", WEBAPP_URL);
inviteUrl.searchParams.set("token", inviteToken);
inviteCallbackUrl = inviteUrl.toString();
}
await sendVerificationEmail({
id: user.id,
email: user.email,
locale: user.locale,
callbackUrl: inviteCallbackUrl,
});
}
}
@@ -11,6 +11,7 @@ import Turnstile, { useTurnstile } from "react-turnstile";
import { z } from "zod";
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links";
import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
@@ -84,7 +85,7 @@ export const SignupForm = ({
const turnstile = useTurnstile();
const callbackUrl = useMemo(() => {
const returnToUrl = useMemo(() => {
if (inviteToken) {
return webAppUrl + "/invite?token=" + inviteToken;
} else {
@@ -125,7 +126,10 @@ export const SignupForm = ({
const url = emailVerificationDisabled
? `/auth/signup-without-verification-success?token=${token}`
: `/auth/verification-requested?token=${token}`;
: buildVerificationRequestedPath({
token: token ?? "",
callbackUrl: inviteToken ? returnToUrl : undefined,
});
if (createUserResponse?.data) {
router.push(url);
@@ -319,7 +323,7 @@ export const SignupForm = ({
samlSsoEnabled={samlSsoEnabled}
samlTenant={samlTenant}
samlProduct={samlProduct}
callbackUrl={callbackUrl}
returnToUrl={returnToUrl}
source="signup"
/>
)}
@@ -328,7 +332,7 @@ export const SignupForm = ({
<span className="leading-5 text-slate-500">{t("auth.signup.have_an_account")}</span>
<br />
<Link
href={inviteToken ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"}
href={inviteToken ? `/auth/login?callbackUrl=${returnToUrl}` : "/auth/login"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
{t("auth.signup.log_in")}
</Link>
@@ -1,9 +1,9 @@
import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM, MOCK_TEAM_USER } from "./__mocks__/team-mocks";
import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM_USER } from "./__mocks__/team-mocks";
import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { createTeamMembership, getTeamProjectIds } from "../team";
import { createTeamMembership, getTeamForOrganization } from "../team";
// Setup all mocks
const setupMocks = () => {
@@ -14,7 +14,7 @@ const setupMocks = () => {
findUnique: vi.fn(),
},
teamUser: {
create: vi.fn(),
upsert: vi.fn(),
},
},
}));
@@ -49,6 +49,8 @@ const setupMocks = () => {
setupMocks();
describe("Team Management", () => {
const mockTeamLookup = { id: MOCK_IDS.teamId };
beforeEach(() => {
vi.clearAllMocks();
});
@@ -56,8 +58,8 @@ describe("Team Management", () => {
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeamLookup as any);
vi.mocked(prisma.teamUser.upsert).mockResolvedValue(MOCK_TEAM_USER as any);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
@@ -66,20 +68,25 @@ describe("Team Management", () => {
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
id: true,
},
});
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
expect(prisma.teamUser.upsert).toHaveBeenCalledWith({
create: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
update: {
role: "admin",
},
where: {
teamId_userId: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
},
},
});
});
});
@@ -91,28 +98,37 @@ describe("Team Management", () => {
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeamLookup as any);
vi.mocked(prisma.teamUser.upsert).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
});
} as any);
await createTeamMembership(nonAdminInvite, MOCK_IDS.userId);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
expect(prisma.teamUser.upsert).toHaveBeenCalledWith({
create: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "contributor",
},
update: {
role: "contributor",
},
where: {
teamId_userId: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
},
},
});
});
});
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeamLookup as any);
vi.mocked(prisma.teamUser.upsert).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
@@ -127,42 +143,47 @@ describe("Team Management", () => {
vi.mocked(prisma.team.findUnique)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(MOCK_TEAM as unknown as any);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
.mockResolvedValueOnce(mockTeamLookup as any);
vi.mocked(prisma.teamUser.upsert).mockResolvedValue(MOCK_TEAM_USER as any);
await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledTimes(2);
expect(prisma.teamUser.create).toHaveBeenCalledTimes(1);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
expect(prisma.teamUser.upsert).toHaveBeenCalledTimes(1);
expect(prisma.teamUser.upsert).toHaveBeenCalledWith({
create: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
update: {
role: "admin",
},
where: {
teamId_userId: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
},
},
});
});
});
});
describe("getTeamProjectIds", () => {
test("returns team with projectTeams when team exists", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any);
describe("getTeamForOrganization", () => {
test("returns the team when it exists in the organization", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeamLookup as any);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
const result = await getTeamForOrganization(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toEqual(MOCK_TEAM);
expect(result).toEqual(mockTeamLookup);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
id: true,
},
});
});
@@ -170,9 +191,31 @@ describe("Team Management", () => {
test("returns null when team does not exist", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId);
const result = await getTeamForOrganization(MOCK_IDS.teamId, MOCK_IDS.organizationId);
expect(result).toBeNull();
});
test("uses the transaction client directly when provided", async () => {
const tx = {
team: {
findUnique: vi.fn().mockResolvedValue(mockTeamLookup),
},
} as any;
const result = await getTeamForOrganization(MOCK_IDS.teamId, MOCK_IDS.organizationId, tx);
expect(result).toEqual(mockTeamLookup);
expect(tx.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
id: true,
},
});
expect(prisma.team.findUnique).not.toHaveBeenCalled();
});
});
});
+60 -30
View File
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { Prisma, PrismaClient, Team } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -7,7 +7,42 @@ import { DatabaseError } from "@formbricks/types/errors";
import { getAccessFlags } from "@/lib/membership/utils";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
type TTeamDbClient = PrismaClient | Prisma.TransactionClient;
type TTeamMembershipTarget = Pick<Team, "id">;
const getDbClient = (tx?: Prisma.TransactionClient): TTeamDbClient => tx ?? prisma;
const getTeamForOrganizationUncached = async (
teamId: string,
organizationId: string,
tx?: Prisma.TransactionClient
): Promise<TTeamMembershipTarget | null> => {
const team = await getDbClient(tx).team.findUnique({
where: {
id: teamId,
organizationId,
},
select: {
id: true,
},
});
if (!team) {
return null;
}
return team;
};
const getTeamForOrganizationCached = reactCache(async (teamId: string, organizationId: string) =>
getTeamForOrganizationUncached(teamId, organizationId)
);
export const createTeamMembership = async (
invite: CreateMembershipInvite,
userId: string,
tx?: Prisma.TransactionClient
): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
@@ -15,20 +50,30 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
const isOwnerOrManager = isOwner || isManager;
try {
const prismaClient = getDbClient(tx);
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
const team = await getTeamForOrganization(teamId, invite.organizationId, tx);
if (!team) {
logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance");
continue;
}
await prisma.teamUser.create({
data: {
await prismaClient.teamUser.upsert({
create: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
update: {
role: isOwnerOrManager ? "admin" : "contributor",
},
where: {
teamId_userId: {
teamId,
userId,
},
},
});
}
} catch (error) {
@@ -41,29 +86,14 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
}
};
export const getTeamProjectIds = reactCache(
async (
teamId: string,
organizationId: string
): Promise<{ projectTeams: { projectId: string }[] } | null> => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (!team) {
return null;
}
return team;
export const getTeamForOrganization = async (
teamId: string,
organizationId: string,
tx?: Prisma.TransactionClient
): Promise<TTeamMembershipTarget | null> => {
if (tx) {
return getTeamForOrganizationUncached(teamId, organizationId, tx);
}
);
return getTeamForOrganizationCached(teamId, organizationId);
};
@@ -29,6 +29,10 @@ vi.mock("@/modules/email", () => ({
sendVerificationEmail: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_type: string, _object: string, fn: Function) => fn),
}));
@@ -133,7 +137,7 @@ describe("resendVerificationEmailAction", () => {
describe("Verification Email Resend Flow", () => {
test("should send verification email when user exists and email is not verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await resendVerificationEmailAction({
@@ -143,12 +147,51 @@ describe("resendVerificationEmailAction", () => {
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).toHaveBeenCalledWith(mockUser);
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
});
expect(result).toEqual({ success: true });
});
test("should preserve a valid callback URL when resending the verification email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: {
...validInput,
callbackUrl: "http://localhost:3000/invite?token=invite-token",
},
} as any);
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
});
test("should discard invalid callback URLs when resending the verification email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: {
...validInput,
callbackUrl: "https://evil.example/phish",
},
} as any);
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
});
});
test("should return success without sending email when user email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const result = await resendVerificationEmailAction({
@@ -163,7 +206,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
@@ -187,7 +230,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should set audit context userId when sending verification email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const testCtx = {
@@ -207,7 +250,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should not set audit context userId when email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const testCtx = {
@@ -241,7 +284,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(
@@ -255,7 +298,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("Email service error"));
@@ -277,7 +320,7 @@ describe("resendVerificationEmailAction", () => {
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
@@ -293,7 +336,7 @@ describe("resendVerificationEmailAction", () => {
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
@@ -320,7 +363,7 @@ describe("resendVerificationEmailAction", () => {
});
test("should not leak information about user existence through different error messages", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(null);
// Both non-existent users should throw the same ResourceNotFoundError
@@ -3,7 +3,9 @@
import { z } from "zod";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -12,6 +14,7 @@ import { sendVerificationEmail } from "@/modules/email";
const ZResendVerificationEmailAction = z.object({
email: ZUserEmail,
callbackUrl: z.string().max(2000).optional(),
});
export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVerificationEmailAction).action(
@@ -28,7 +31,12 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer
};
}
ctx.auditLoggingCtx.userId = user.id;
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
await sendVerificationEmail({
id: user.id,
email: user.email,
locale: user.locale,
callbackUrl: getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined,
});
return {
success: true,
};
@@ -9,9 +9,10 @@ import { resendVerificationEmailAction } from "../actions";
interface RequestVerificationEmailProps {
email: string | null;
callbackUrl?: string | null;
}
export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProps) => {
export const RequestVerificationEmail = ({ email, callbackUrl }: RequestVerificationEmailProps) => {
const { t } = useTranslation();
useEffect(() => {
const handleVisibilityChange = () => {
@@ -29,7 +30,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp
const requestVerificationEmail = async () => {
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
const response = await resendVerificationEmailAction({ email });
const response = await resendVerificationEmailAction({ email, callbackUrl: callbackUrl ?? undefined });
if (response?.data) {
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
} else {
@@ -1,18 +1,28 @@
import { cookies } from "next/headers";
import { logger } from "@formbricks/logger";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { getEmailFromEmailToken } from "@/lib/jwt";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { getAuthCallbackUrlFromCookies, resolveAuthCallbackUrl } from "@/modules/auth/lib/callback-url";
import { RequestVerificationEmail } from "@/modules/auth/verification-requested/components/request-verification-email";
import { VerificationMessage } from "@/modules/auth/verification-requested/components/verification-message";
export const VerificationRequestedPage = async ({
searchParams,
}: {
searchParams: Promise<{ token: string }>;
searchParams: Promise<{ token: string; callbackUrl?: string | string[] }>;
}) => {
const t = await getTranslate();
const { token } = await searchParams;
const [params, cookieStore] = await Promise.all([searchParams, cookies()]);
const { token, callbackUrl } = params;
const resolvedCallbackUrl = resolveAuthCallbackUrl({
searchParamCallbackUrl: callbackUrl,
cookieCallbackUrl: getAuthCallbackUrlFromCookies(cookieStore),
allowCookieFallback: true,
webAppUrl: WEBAPP_URL,
});
try {
const email = getEmailFromEmailToken(token);
const parsedEmail = ZUserEmail.safeParse(email);
@@ -29,7 +39,7 @@ export const VerificationRequestedPage = async ({
{t("auth.verification-requested.you_didnt_receive_an_email_or_your_link_expired")}
</p>
<div className="mt-5">
<RequestVerificationEmail email={email.toLowerCase()} />
<RequestVerificationEmail email={email.toLowerCase()} callbackUrl={resolvedCallbackUrl} />
</div>
</>
</FormWrapper>
@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export const SignIn = ({ token, webAppUrl }: { token: string; webAppUrl: string }) => {
export const SignIn = ({ token, callbackUrl }: { token: string; callbackUrl: string }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
callbackUrl: webAppUrl,
callbackUrl,
});
}
}, [token, webAppUrl]);
}, [callbackUrl, token]);
return <></>;
};
+17 -3
View File
@@ -1,16 +1,30 @@
import { cookies } from "next/headers";
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { getAuthCallbackUrlFromCookies, resolveAuthCallbackUrl } from "@/modules/auth/lib/callback-url";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
export const VerifyPage = async ({ searchParams }: { searchParams: Promise<{ token?: string }> }) => {
export const VerifyPage = async ({
searchParams,
}: {
searchParams: Promise<{ token?: string; callbackUrl?: string | string[] }>;
}) => {
const t = await getTranslate();
const { token } = await searchParams;
const [params, cookieStore] = await Promise.all([searchParams, cookies()]);
const { token, callbackUrl } = params;
const resolvedCallbackUrl =
resolveAuthCallbackUrl({
searchParamCallbackUrl: callbackUrl,
cookieCallbackUrl: getAuthCallbackUrlFromCookies(cookieStore),
allowCookieFallback: true,
webAppUrl: WEBAPP_URL,
}) ?? WEBAPP_URL;
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} webAppUrl={WEBAPP_URL} />
<SignIn token={token} callbackUrl={resolvedCallbackUrl} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual([
"emailUpdate",
"accountDeletion",
"surveyFollowUp",
"sendLinkSurveyEmail",
"licenseRecheck",
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
// Server actions - varies by action type
actions: {
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
sendLinkSurveyEmail: {
interval: 3600,
@@ -1,4 +1,4 @@
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
import { responses } from "@/app/lib/api/response";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
@@ -4,30 +4,30 @@ import { signIn } from "next-auth/react";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { getSsoReturnToUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { MicrosoftIcon } from "@/modules/ui/components/icons";
interface AzureButtonProps {
inviteUrl?: string;
returnToUrl?: string;
directRedirect?: boolean;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const AzureButton = ({ inviteUrl, directRedirect = false, lastUsed, source }: AzureButtonProps) => {
export const AzureButton = ({ returnToUrl, directRedirect = false, lastUsed, source }: AzureButtonProps) => {
const { t } = useTranslation();
const handleLogin = useCallback(async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Azure");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source);
await signIn("azure-ad", {
redirect: true,
callbackUrl: callbackUrlWithSource,
callbackUrl: returnToUrlWithSource,
});
}, [inviteUrl, source]);
}, [returnToUrl, source]);
useEffect(() => {
if (directRedirect) {
@@ -3,27 +3,27 @@
import { signIn } from "next-auth/react";
import { useTranslation } from "react-i18next";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { getSsoReturnToUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { GithubIcon } from "@/modules/ui/components/icons";
interface GithubButtonProps {
inviteUrl?: string;
returnToUrl?: string;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const GithubButton = ({ inviteUrl, lastUsed, source }: GithubButtonProps) => {
export const GithubButton = ({ returnToUrl, lastUsed, source }: GithubButtonProps) => {
const { t } = useTranslation();
const handleLogin = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Github");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source);
await signIn("github", {
redirect: true,
callbackUrl: callbackUrlWithSource,
callbackUrl: returnToUrlWithSource,
});
};
@@ -3,27 +3,27 @@
import { signIn } from "next-auth/react";
import { useTranslation } from "react-i18next";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { getSsoReturnToUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { GoogleIcon } from "@/modules/ui/components/icons";
interface GoogleButtonProps {
inviteUrl?: string;
returnToUrl?: string;
lastUsed?: boolean;
source: "signin" | "signup";
}
export const GoogleButton = ({ inviteUrl, lastUsed, source }: GoogleButtonProps) => {
export const GoogleButton = ({ returnToUrl, lastUsed, source }: GoogleButtonProps) => {
const { t } = useTranslation();
const handleLogin = async () => {
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Google");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source);
await signIn("google", {
redirect: true,
callbackUrl: callbackUrlWithSource,
callbackUrl: returnToUrlWithSource,
});
};
@@ -4,11 +4,11 @@ import { signIn } from "next-auth/react";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { getSsoReturnToUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
interface OpenIdButtonProps {
inviteUrl?: string;
returnToUrl?: string;
lastUsed?: boolean;
directRedirect?: boolean;
text?: string;
@@ -16,7 +16,7 @@ interface OpenIdButtonProps {
}
export const OpenIdButton = ({
inviteUrl,
returnToUrl,
lastUsed,
directRedirect = false,
text,
@@ -27,13 +27,13 @@ export const OpenIdButton = ({
if (typeof window !== "undefined") {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "OpenID");
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source);
await signIn("openid", {
redirect: true,
callbackUrl: callbackUrlWithSource,
callbackUrl: returnToUrlWithSource,
});
}, [inviteUrl, source]);
}, [returnToUrl, source]);
useEffect(() => {
if (directRedirect) {
@@ -7,18 +7,18 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
import { getCallbackUrl } from "@/modules/ee/sso/lib/utils";
import { getSsoReturnToUrl } from "@/modules/ee/sso/lib/utils";
import { Button } from "@/modules/ui/components/button";
interface SamlButtonProps {
inviteUrl?: string;
returnToUrl?: string;
lastUsed?: boolean;
samlTenant: string;
samlProduct: string;
source: "signin" | "signup";
}
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct, source }: SamlButtonProps) => {
export const SamlButton = ({ returnToUrl, lastUsed, samlTenant, samlProduct, source }: SamlButtonProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
@@ -34,13 +34,13 @@ export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct, sourc
return;
}
const callbackUrlWithSource = getCallbackUrl(inviteUrl, source);
const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source);
signIn(
"saml",
{
redirect: true,
callbackUrl: callbackUrlWithSource,
callbackUrl: returnToUrlWithSource,
},
{
tenant: samlTenant,
@@ -15,7 +15,7 @@ interface SSOOptionsProps {
azureOAuthEnabled: boolean;
oidcOAuthEnabled: boolean;
oidcDisplayName?: string;
callbackUrl: string;
returnToUrl: string;
samlSsoEnabled: boolean;
samlTenant: string;
samlProduct: string;
@@ -28,7 +28,7 @@ export const SSOOptions = ({
azureOAuthEnabled,
oidcOAuthEnabled,
oidcDisplayName,
callbackUrl,
returnToUrl,
samlSsoEnabled,
samlTenant,
samlProduct,
@@ -46,17 +46,17 @@ export const SSOOptions = ({
return (
<div className="space-y-2">
{googleOAuthEnabled && (
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} source={source} />
<GoogleButton returnToUrl={returnToUrl} lastUsed={lastLoggedInWith === "Google"} source={source} />
)}
{githubOAuthEnabled && (
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} source={source} />
<GithubButton returnToUrl={returnToUrl} lastUsed={lastLoggedInWith === "Github"} source={source} />
)}
{azureOAuthEnabled && (
<AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} source={source} />
<AzureButton returnToUrl={returnToUrl} lastUsed={lastLoggedInWith === "Azure"} source={source} />
)}
{oidcOAuthEnabled && (
<OpenIdButton
inviteUrl={callbackUrl}
returnToUrl={returnToUrl}
lastUsed={lastLoggedInWith === "OpenID"}
text={t("auth.continue_with_oidc", { oidcDisplayName })}
source={source}
@@ -64,7 +64,7 @@ export const SSOOptions = ({
)}
{samlSsoEnabled && (
<SamlButton
inviteUrl={callbackUrl}
returnToUrl={returnToUrl}
lastUsed={lastLoggedInWith === "Saml"}
samlTenant={samlTenant}
samlProduct={samlProduct}
@@ -0,0 +1,39 @@
import type { IdentityProvider } from "@prisma/client";
const SSO_PROVIDER_MAP = {
google: "google",
github: "github",
"azure-ad": "azuread",
azuread: "azuread",
openid: "openid",
saml: "saml",
} as const satisfies Record<string, IdentityProvider>;
const LEGACY_SSO_PROVIDER_ALIASES: Partial<Record<IdentityProvider, string[]>> = {
azuread: ["azure-ad"],
};
const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP =>
provider in SSO_PROVIDER_MAP;
export const normalizeSsoProvider = (provider: string): IdentityProvider | null => {
const normalizedProviderKey = provider.toLowerCase();
if (!isSupportedSsoProvider(normalizedProviderKey)) {
return null;
}
return SSO_PROVIDER_MAP[normalizedProviderKey];
};
export const getLegacySsoProviderAliases = (provider: IdentityProvider): string[] =>
LEGACY_SSO_PROVIDER_ALIASES[provider] ?? [];
export const getSsoProviderLookupCandidates = (provider: string): string[] => {
const normalizedProvider = normalizeSsoProvider(provider);
if (!normalizedProvider) {
return [];
}
return [normalizedProvider, ...getLegacySsoProviderAliases(normalizedProvider)];
};
@@ -41,6 +41,7 @@ describe("SSO Providers", () => {
test("should configure SAML provider correctly", () => {
const providers = getSSOProviders();
const samlProvider = providers[4];
const googleProvider = providers[1];
expect(samlProvider.id).toBe("saml");
expect(samlProvider.name).toBe("BoxyHQ SAML");
@@ -50,6 +51,7 @@ describe("SSO Providers", () => {
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
expect(samlProvider.allowDangerousEmailAccountLinking).toBe(true);
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBeUndefined();
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
});
});
-2
View File
@@ -26,7 +26,6 @@ export const getSSOProviders = () => [
GoogleProvider({
clientId: GOOGLE_CLIENT_ID || "",
clientSecret: GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
AzureAD({
clientId: AZUREAD_CLIENT_ID || "",
@@ -81,7 +80,6 @@ export const getSSOProviders = () => [
clientId: "dummy",
clientSecret: "dummy",
},
allowDangerousEmailAccountLinking: true,
},
];

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