mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-21 03:31:20 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf2ef36ceb | |||
| aa040595c3 | |||
| 81c2bd365a | |||
| b26945698d | |||
| 208d83eb08 | |||
| 0a7482da0f |
+12
-8
@@ -76,10 +76,12 @@ HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disabl
|
||||
# EMBEDDING_MODEL=gemini-embedding-001
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
|
||||
####################
|
||||
# CUBE ANALYTICS #
|
||||
####################
|
||||
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
|
||||
###########################
|
||||
# CUBE ANALYTICS (XM V5) #
|
||||
###########################
|
||||
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
|
||||
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
|
||||
# COMPOSE_PROFILES=xm
|
||||
CUBEJS_API_URL=http://localhost:4000
|
||||
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||
CUBEJS_API_SECRET=
|
||||
@@ -155,12 +157,11 @@ PASSWORD_RESET_DISABLED=1
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion SSO confirmation #
|
||||
# Account deletion reauthentication #
|
||||
###########################################
|
||||
|
||||
# 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
|
||||
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
|
||||
|
||||
##########
|
||||
@@ -188,6 +189,9 @@ GITHUB_SECRET=
|
||||
# Configure Google Login
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
|
||||
# Keep this unset until that setting is active for the OAuth app.
|
||||
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
|
||||
# Configure Azure Active Directory Login
|
||||
AZUREAD_CLIENT_ID=
|
||||
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Get the latest release tag from GitHub API with error handling
|
||||
echo "Fetching latest release from GitHub API..."
|
||||
|
||||
|
||||
# Use curl with error handling - API returns 404 if no releases exist
|
||||
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
|
||||
|
||||
|
||||
if [[ "$http_code" == "404" ]]; then
|
||||
echo "⚠️ No previous releases found (404). This appears to be the first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
echo "Current release tag: ${{ github.event.release.tag_name }}"
|
||||
|
||||
- name: Compare release tags
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Handle first release case (no previous releases)
|
||||
if [[ -z "${LATEST_TAG}" ]]; then
|
||||
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
|
||||
@@ -156,87 +156,6 @@ jobs:
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
update-helm-app-version:
|
||||
name: Create Helm app version update
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- helm-chart-release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Prepare Helm app version update
|
||||
id: update
|
||||
env:
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
|
||||
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
|
||||
|
||||
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
|
||||
echo "Helm chart appVersion already matches ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Helm app version PR
|
||||
if: steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="chore/update-helm-app-version-${VERSION}"
|
||||
title="chore: update Helm app version to ${VERSION}"
|
||||
body_file="$(mktemp)"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$branch"
|
||||
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
cat > "$body_file" <<EOF
|
||||
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
|
||||
|
||||
Release candidates and pre-releases do not create this source update.
|
||||
EOF
|
||||
|
||||
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
|
||||
else
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
@@ -246,7 +165,6 @@ jobs:
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
- update-helm-app-version
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
|
||||
@@ -70,25 +70,6 @@ jobs:
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
- name: Validate default Formbricks image tag
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rendered="$(helm template qa charts/formbricks \
|
||||
--set formbricks.webappUrl=https://qa.example.com \
|
||||
--show-only templates/deployment.yaml \
|
||||
--show-only templates/migration-job.yaml)"
|
||||
|
||||
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
|
||||
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
|
||||
if [[ "$image_count" -ne 2 ]]; then
|
||||
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
|
||||
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const Page = async (props: ModePageProps) => {
|
||||
}
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
|
||||
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
|
||||
|
||||
if (experimentVariant === "remove-cx-and-surveys-mode") {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
|
||||
|
||||
@@ -194,7 +194,7 @@ export const MainNavigation = ({
|
||||
const settingsNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.settings"),
|
||||
href: `/workspaces/${workspace.id}/settings/workspace/general`,
|
||||
href: `/workspaces/${workspace.id}/settings`,
|
||||
icon: SettingsIcon,
|
||||
isActive: isSettingsMode,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
@@ -467,7 +467,7 @@ export const MainNavigation = ({
|
||||
{isSettingsMode ? (
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="mb-2 px-3">
|
||||
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
|
||||
<GoBackButton />
|
||||
</div>
|
||||
|
||||
{/* Settings sidebar content */}
|
||||
|
||||
@@ -335,7 +335,6 @@ export const SettingsSidebarContent = ({
|
||||
href: `${basePath}/organization/feedback-directories`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
hidden: isMember,
|
||||
disabled: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-api-keys",
|
||||
@@ -374,14 +373,12 @@ export const SettingsSidebarContent = ({
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${basePath}/account/notifications`,
|
||||
icon: <BellIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const AccountSettingsLayout = async (props: Readonly<{
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
||||
|
||||
+11
-11
@@ -8,8 +8,8 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
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";
|
||||
@@ -22,7 +22,6 @@ interface DeleteAccountProps {
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
isSsoIdentityConfirmationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
@@ -33,7 +32,6 @@ export const DeleteAccount = ({
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
requiresPasswordConfirmation,
|
||||
isSsoIdentityConfirmationDisabled,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
@@ -44,18 +42,21 @@ export const DeleteAccount = ({
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
|
||||
hasShownAccountDeletionError.current
|
||||
) {
|
||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
toast.error(t("workspace.settings.profile.sso_identity_confirmation_failed"), {
|
||||
id: "account-deletion-sso-confirmation-error",
|
||||
});
|
||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
toast.error(t("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("workspace.settings.profile.sso_reauthentication_failed"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
@@ -75,7 +76,6 @@ export const DeleteAccount = ({
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
|
||||
/>
|
||||
<p className="text-sm text-slate-700">
|
||||
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/acc
|
||||
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
@@ -99,7 +98,6 @@ const Page = async (props: {
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getBillingFallbackPath: vi.fn(),
|
||||
getWorkspaceAuth: vi.fn(),
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/navigation", () => ({
|
||||
getBillingFallbackPath: mocks.getBillingFallbackPath,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/workspaces/lib/utils", () => ({
|
||||
getWorkspaceAuth: mocks.getWorkspaceAuth,
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-1";
|
||||
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
|
||||
|
||||
const getWorkspaceAuthResponse = (isBilling: boolean) =>
|
||||
({
|
||||
isBilling,
|
||||
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
|
||||
|
||||
describe("redirectBillingRoleFromRestrictedSettings", () => {
|
||||
test("does not redirect non-billing workspace members", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
|
||||
|
||||
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).not.toHaveBeenCalled();
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects billing users to the billing fallback path", async () => {
|
||||
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
|
||||
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
|
||||
|
||||
await redirectBillingRoleFromRestrictedSettings(workspaceId);
|
||||
|
||||
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
|
||||
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
|
||||
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
|
||||
const { isBilling } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
if (isBilling) {
|
||||
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return APIKeysPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default APIKeysPage;
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { PricingPage } from "@/modules/ee/billing/page";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && !IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
return PricingPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default PricingPage;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -13,9 +12,8 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
|
||||
+4
-9
@@ -1,10 +1,9 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -12,19 +11,15 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (isBilling && IS_FORMBRICKS_CLOUD) {
|
||||
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
if (isPricingDisabled) {
|
||||
|
||||
+1
-11
@@ -1,11 +1 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return FeedbackDirectoriesPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
@@ -27,9 +26,8 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
|
||||
return TeamsPage(props);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default TeamsPage;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
|
||||
|
||||
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
|
||||
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
|
||||
};
|
||||
|
||||
|
||||
+39
-8
@@ -5,29 +5,35 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { ResponseSampleModal } from "./ResponseSampleModal";
|
||||
|
||||
interface OpenTextSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryOpenText;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({
|
||||
elementSummary,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: Readonly<OpenTextSummaryProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
setVisibleResponses((prevVisibleResponses) =>
|
||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
||||
);
|
||||
@@ -48,17 +54,31 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
||||
<TableHead className="w-1/6">{t("common.time")}</TableHead>
|
||||
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableRow
|
||||
key={response.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("workspace.surveys.summary.open_response_details")}
|
||||
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
onClick={() => setSelectedResponseId(response.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedResponseId(response.id);
|
||||
}
|
||||
}}>
|
||||
<TableCell className="w-1/4">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
|
||||
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
@@ -80,9 +100,12 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
<TableCell className="w-1/6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
|
||||
<IdBadge id={response.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -96,6 +119,14 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponseSampleModal
|
||||
responseId={selectedResponseId}
|
||||
onClose={() => setSelectedResponseId(null)}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import {
|
||||
getResponseAction,
|
||||
getTagsByWorkspaceIdAction,
|
||||
} from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ResponseSampleModalProps {
|
||||
responseId: string | null;
|
||||
onClose: () => void;
|
||||
survey: TSurvey;
|
||||
isReadOnly: boolean;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ResponseSampleModal = ({
|
||||
responseId,
|
||||
onClose,
|
||||
survey,
|
||||
isReadOnly,
|
||||
locale,
|
||||
}: Readonly<ResponseSampleModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
|
||||
const [tags, setTags] = useState<TTag[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Cache fetched data per response ID to avoid re-fetching on re-open
|
||||
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
|
||||
// Track the in-flight request so stale resolutions can be ignored when the user
|
||||
// switches rows quickly.
|
||||
const latestRequestId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!responseId) return;
|
||||
|
||||
const cached = cache.current.get(responseId);
|
||||
if (cached) {
|
||||
setResponse(cached.response);
|
||||
setTags(cached.tags);
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
latestRequestId.current = responseId;
|
||||
setIsLoading(true);
|
||||
setResponse(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
Promise.all([
|
||||
getResponseAction({ responseId }),
|
||||
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
|
||||
])
|
||||
.then(([responseResult, tagsResult]) => {
|
||||
// Discard if a newer request has started or the modal has been closed.
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
|
||||
const responseError = getFormattedErrorMessage(responseResult);
|
||||
const tagsError = getFormattedErrorMessage(tagsResult);
|
||||
const fetchedResponse = responseResult?.data ?? null;
|
||||
const fetchedTags = tagsResult?.data ?? [];
|
||||
|
||||
if (responseError || tagsError || !fetchedResponse) {
|
||||
const message = responseError || tagsError || t("common.something_went_wrong");
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { response: fetchedResponse, tags: fetchedTags };
|
||||
cache.current.set(responseId, entry);
|
||||
setResponse(entry.response);
|
||||
setTags(entry.tags);
|
||||
})
|
||||
.catch(() => {
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
const message = t("common.something_went_wrong");
|
||||
toast.error(message);
|
||||
setErrorMessage(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (latestRequestId.current !== responseId) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [responseId, survey.workspaceId, t]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
// Drop any in-flight request so it can't commit after close.
|
||||
latestRequestId.current = null;
|
||||
setErrorMessage(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
|
||||
<DialogContent width="wide">
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>{t("common.response")}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>{t("common.response")}</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
|
||||
) : response ? (
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
response={response}
|
||||
environmentTags={tags}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={locale}
|
||||
/>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+9
-1
@@ -41,9 +41,16 @@ interface SummaryListProps {
|
||||
responseCount: number | null;
|
||||
survey: TSurvey;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
|
||||
export const SummaryList = ({
|
||||
summary,
|
||||
responseCount,
|
||||
survey,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: Readonly<SummaryListProps>) => {
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
@@ -116,6 +123,7 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -49,6 +49,7 @@ interface SummaryPageProps {
|
||||
locale: TUserLocale;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
isQuotasAllowed: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SummaryPage = ({
|
||||
@@ -57,7 +58,8 @@ export const SummaryPage = ({
|
||||
locale,
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
isReadOnly,
|
||||
}: Readonly<SummaryPageProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -225,6 +227,7 @@ export const SummaryPage = ({
|
||||
responseCount={surveySummary.meta.totalResponses}
|
||||
survey={surveyMemoized}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
|
||||
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -88,6 +88,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
|
||||
|
||||
-4
@@ -11,7 +11,6 @@ import {
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GaugeIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmilePlusIcon,
|
||||
SmartphoneIcon,
|
||||
StarIcon,
|
||||
User,
|
||||
@@ -105,8 +103,6 @@ const elementIcons = {
|
||||
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyElementTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
|
||||
[TSurveyElementTypeEnum.CES]: GaugeIcon,
|
||||
[TSurveyElementTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
|
||||
+27
-60
@@ -3,17 +3,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { 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 { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockConstants = vi.hoisted(() => ({
|
||||
isFormbricksCloud: false,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
@@ -26,9 +21,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockConstants.isFormbricksCloud;
|
||||
},
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
@@ -44,15 +37,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
|
||||
queueAccountDeletionAuditEvent: vi.fn(),
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
const mockLoggerError = vi.mocked(logger.error);
|
||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
||||
const mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
|
||||
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
|
||||
|
||||
const intent = {
|
||||
id: "intent-id",
|
||||
@@ -64,10 +57,9 @@ const intent = {
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
|
||||
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConstants.isFormbricksCloud = false;
|
||||
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
@@ -79,22 +71,22 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
||||
oldUser: { id: intent.userId } as any,
|
||||
});
|
||||
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
|
||||
mockQueueAuditEventBackground.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns login without deleting when the callback has no intent", async () => {
|
||||
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
|
||||
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
|
||||
"/auth/login"
|
||||
);
|
||||
|
||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
|
||||
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes the account after a completed SSO identity confirmation", async () => {
|
||||
test("deletes the account after a completed SSO reauthentication", async () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
@@ -102,24 +94,15 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
oldUser: { id: intent.userId },
|
||||
status: "success",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("redirects to the account deletion survey after SSO identity confirmation on Formbricks Cloud", async () => {
|
||||
mockConstants.isFormbricksCloud = true;
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: intent.userId,
|
||||
userType: "user",
|
||||
targetId: intent.userId,
|
||||
organizationId: "unknown",
|
||||
oldObject: { id: intent.userId },
|
||||
status: "success",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,43 +115,27 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(AuthorizationError) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
|
||||
new AuthorizationError("marker missing")
|
||||
);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
|
||||
status: "failure",
|
||||
targetUserId: intent.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
||||
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
|
||||
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Failed to complete account deletion after SSO identity confirmation"
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -185,7 +152,7 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
|
||||
+20
-26
@@ -5,14 +5,11 @@ 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 { 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";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
@@ -26,7 +23,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
@@ -34,23 +31,17 @@ const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
}
|
||||
|
||||
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 ({
|
||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = 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;
|
||||
@@ -58,30 +49,33 @@ export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath =
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
targetUserId = verifiedIntent.userId;
|
||||
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
|
||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
|
||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
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");
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: session.user.id,
|
||||
userType: "user",
|
||||
targetId: session.user.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status: "success",
|
||||
});
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
||||
} catch (error) {
|
||||
if (targetUserId && !deletionSucceeded) {
|
||||
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
|
||||
}
|
||||
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoReauthCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
const getIntentSearchParam = (request: NextRequest): string | string[] | undefined => {
|
||||
const intentValues = request.nextUrl.searchParams.getAll("intent");
|
||||
|
||||
if (intentValues.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return intentValues.length === 1 ? intentValues[0] : intentValues;
|
||||
};
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const redirectPath = await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({
|
||||
intent: getIntentSearchParam(request),
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL(redirectPath, request.url));
|
||||
};
|
||||
@@ -34,7 +34,7 @@ export const GET = async (req: Request) => {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
@@ -102,7 +102,7 @@ export const GET = async (req: Request) => {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
return Response.redirect(`${WEBAPP_URL}${basePath}/integrations/google-sheets`);
|
||||
return Response.redirect(`${WEBAPP_URL}/${basePath}/integrations/google-sheets`);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Failed to create or update Google Sheets integration");
|
||||
|
||||
@@ -103,7 +103,6 @@ describe("getWorkspaceStateData", () => {
|
||||
id: workspaceId,
|
||||
appSetupCompleted: true,
|
||||
workspaceSettings: {
|
||||
id: workspaceId,
|
||||
recontactDays: 30,
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
@@ -112,14 +111,7 @@ describe("getWorkspaceStateData", () => {
|
||||
styling: { allowStyleOverwrite: false },
|
||||
},
|
||||
},
|
||||
// `survey.name` is replaced with a back-compat placeholder; segment was
|
||||
// null in the mock so the sanitized segment stays null.
|
||||
surveys: [
|
||||
{
|
||||
...mockWorkspaceData.surveys[0],
|
||||
name: "[deprecated] survey name omitted from public API - will be removed soon",
|
||||
},
|
||||
],
|
||||
surveys: mockWorkspaceData.surveys,
|
||||
actionClasses: mockWorkspaceData.actionClasses,
|
||||
});
|
||||
|
||||
@@ -219,7 +211,6 @@ describe("getWorkspaceStateData", () => {
|
||||
const result = await getWorkspaceStateData(workspaceId);
|
||||
|
||||
expect(result.workspace.workspaceSettings).toEqual({
|
||||
id: workspaceId,
|
||||
recontactDays: 14,
|
||||
clickOutsideClose: false,
|
||||
overlay: "dark",
|
||||
|
||||
@@ -42,7 +42,6 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
where: { id: workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
legacyEnvironmentId: true,
|
||||
appSetupCompleted: true,
|
||||
recontactDays: true,
|
||||
clickOutsideClose: true,
|
||||
@@ -73,9 +72,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
// `name` deliberately not selected — internal label not needed by the
|
||||
// SDK and replaced with a fixed placeholder below so older SDKs that
|
||||
// decoded `Survey.name` as a required field keep working.
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -102,9 +99,9 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
styling: true,
|
||||
status: true,
|
||||
recaptcha: true,
|
||||
// Only need to know if any filters exist so we can compute
|
||||
// `hasFilters`. Real filter values, segment title/description, and
|
||||
// surveys-list relation are never exposed to clients.
|
||||
// Fetch only what's needed to compute the minimal segment shape.
|
||||
// Titles, descriptions, and filter conditions are evaluated server-side
|
||||
// and must not be sent to the browser.
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -138,46 +135,17 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
throw new ResourceNotFoundError("workspace", workspaceId);
|
||||
}
|
||||
|
||||
// Backwards-compat response shape for SDKs from before PR #7931. Those
|
||||
// clients decoded `survey.name` and the full `segment` object as required
|
||||
// fields, so the response must still carry that shape — but every field
|
||||
// that could leak sensitive targeting data is replaced with a placeholder.
|
||||
// The actual segment-membership check happens server-side (segment IDs in
|
||||
// POST /user); SDKs only inspect `filters.length` / `hasFilters` locally.
|
||||
//
|
||||
// `environmentId` mirrors `legacyEnvironmentId ?? workspace.id`, matching
|
||||
// the `/me` endpoints' pattern so migrated workspaces keep returning the
|
||||
// original env ID older clients persisted.
|
||||
const legacyOrCurrentId = workspaceData.legacyEnvironmentId ?? workspaceData.id;
|
||||
const placeholderDate = new Date(0);
|
||||
const placeholderFilter = {
|
||||
id: "placeholder",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "placeholder",
|
||||
root: { type: "device", deviceType: "phone" },
|
||||
value: "deprecated",
|
||||
qualifier: { operator: "equals" },
|
||||
},
|
||||
};
|
||||
|
||||
// Transform surveys using the shared utility, then replace the segment with
|
||||
// the minimal public shape (id + hasFilters). We null out segment before
|
||||
// calling transformPrismaSurvey because that function expects a surveys[]
|
||||
// relation on the segment object (used by the management API), which we
|
||||
// intentionally don't fetch here.
|
||||
const transformedSurveys = workspaceData.surveys.map((survey) => {
|
||||
const realHasFilters =
|
||||
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
|
||||
|
||||
const sanitizedSegment = survey.segment
|
||||
const minimalSegment = survey.segment
|
||||
? {
|
||||
id: survey.segment.id,
|
||||
title: "[deprecated] segment title omitted from public API - will be removed soon",
|
||||
description: null,
|
||||
isPrivate: true,
|
||||
filters: realHasFilters ? [placeholderFilter] : [],
|
||||
environmentId: legacyOrCurrentId,
|
||||
workspaceId: legacyOrCurrentId,
|
||||
createdAt: placeholderDate,
|
||||
updatedAt: placeholderDate,
|
||||
surveys: [],
|
||||
hasFilters: realHasFilters,
|
||||
hasFilters:
|
||||
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -187,11 +155,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return {
|
||||
...transformed,
|
||||
name: "[deprecated] survey name omitted from public API - will be removed soon",
|
||||
segment: sanitizedSegment,
|
||||
};
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -199,7 +163,6 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
id: workspaceData.id,
|
||||
appSetupCompleted: workspaceData.appSetupCompleted,
|
||||
workspaceSettings: {
|
||||
id: workspaceData.id,
|
||||
recontactDays: workspaceData.recontactDays,
|
||||
clickOutsideClose: workspaceData.clickOutsideClose,
|
||||
overlay: workspaceData.overlay,
|
||||
@@ -208,11 +171,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
styling: resolveStorageUrlsInObject(workspaceData.styling),
|
||||
},
|
||||
},
|
||||
// The runtime shape carries extra back-compat fields (placeholder
|
||||
// segment, `hasFilters`, mirrored `environmentId`) that aren't part of
|
||||
// the modern `TJsWorkspaceStateSurvey`. Cast through unknown — this is
|
||||
// intentional and only this endpoint's response widens the type.
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys) as unknown as TJsWorkspaceStateSurvey[],
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: workspaceData.actionClasses,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
-34
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
@@ -35,10 +34,6 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
resolveClientApiIds: mocks.resolveClientApiIds,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
|
||||
validateResponseData: mocks.validateResponseData,
|
||||
@@ -128,7 +123,6 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
@@ -245,34 +239,6 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns not found when the workspace id cannot be resolved", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Workspace not found",
|
||||
details: {
|
||||
resource_id: "unknown_workspace_or_env",
|
||||
resource_type: "Workspace",
|
||||
},
|
||||
});
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
|
||||
|
||||
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
|
||||
+1
-10
@@ -8,7 +8,6 @@ import { THandlerParams } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -210,7 +209,7 @@ export const putResponseHandler = async ({
|
||||
props,
|
||||
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
|
||||
const params = await props.params;
|
||||
const { workspaceId: workspaceIdParam, responseId } = params;
|
||||
const { workspaceId, responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
@@ -218,14 +217,6 @@ export const putResponseHandler = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveClientApiIds(workspaceIdParam);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Workspace", workspaceIdParam, true),
|
||||
};
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
|
||||
if ("response" in validatedUpdateInput) {
|
||||
return validatedUpdateInput;
|
||||
|
||||
@@ -104,11 +104,7 @@ export const createResponse = async (
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData = buildPrismaResponseData(
|
||||
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
|
||||
contact,
|
||||
ttc
|
||||
);
|
||||
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
|
||||
|
||||
const prismaClient = tx ?? prisma;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
import { getErrorResponseFromStorageError, validateSurveyAllowsFileUpload } from "@/modules/storage/utils";
|
||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
@@ -107,23 +107,6 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const fileUploadPermission = validateSurveyAllowsFileUpload({
|
||||
fileName,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
});
|
||||
|
||||
if (!fileUploadPermission.ok) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
fileUploadPermission.reason === "no_file_upload_question"
|
||||
? "Survey does not allow file uploads"
|
||||
: "File extension is not allowed for this survey",
|
||||
undefined
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
|
||||
const maxFileUploadSize = isBiggerFileUploadAllowed
|
||||
? MAX_FILE_UPLOAD_SIZES.big
|
||||
|
||||
@@ -51,7 +51,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
const client_id = AIRTABLE_CLIENT_ID;
|
||||
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
|
||||
|
||||
@@ -40,7 +40,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings/workspace`;
|
||||
const basePath = `/workspaces/${workspaceId}`;
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -21,25 +21,24 @@ vi.mock("react", async () => {
|
||||
});
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const workspaceId = "test-workspace-id";
|
||||
|
||||
describe("doesContactExistInWorkspace", () => {
|
||||
describe("doesContactExist", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return true if contact exists in the workspace", async () => {
|
||||
test("should return true if contact exists", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({
|
||||
id: contactId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const result = await doesContactExistInWorkspace(contactId, workspaceId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, workspaceId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
@@ -47,11 +46,11 @@ describe("doesContactExistInWorkspace", () => {
|
||||
test("should return false if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await doesContactExistInWorkspace(contactId, workspaceId);
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId, workspaceId },
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const doesContactExistInWorkspace = reactCache(
|
||||
async (id: string, workspaceId: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!contact;
|
||||
}
|
||||
);
|
||||
return !!contact;
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { TDisplayCreateInputV2 } from "../types/display";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
import { createDisplay } from "./display";
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
@@ -30,7 +30,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./contact", () => ({
|
||||
doesContactExistInWorkspace: vi.fn(),
|
||||
doesContactExist: vi.fn(),
|
||||
}));
|
||||
|
||||
const workspaceId = "workspace-id-mock";
|
||||
@@ -81,13 +81,13 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
test("should create a display with contactId successfully", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -104,7 +104,7 @@ describe("createDisplay", () => {
|
||||
const result = await createDisplay(displayInputWithoutContact);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
|
||||
expect(doesContactExist).not.toHaveBeenCalled();
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -115,13 +115,13 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
test("should create a display without contact if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(false);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(false);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -139,16 +139,16 @@ describe("createDisplay", () => {
|
||||
});
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
|
||||
expect(doesContactExistInWorkspace).not.toHaveBeenCalled();
|
||||
expect(doesContactExist).not.toHaveBeenCalled();
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError when survey does not exist (P2025)", async () => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
|
||||
expect(doesContactExistInWorkspace).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: surveyId, workspaceId },
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("createDisplay", () => {
|
||||
test.each(["draft", "paused", "completed"])(
|
||||
"should throw InvalidInputError when survey status is %s",
|
||||
async (status) => {
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
|
||||
@@ -171,7 +171,7 @@ describe("createDisplay", () => {
|
||||
code: "P2002",
|
||||
clientVersion: "2.0.0",
|
||||
});
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
|
||||
@@ -179,15 +179,15 @@ describe("createDisplay", () => {
|
||||
|
||||
test("should throw original error on other errors during creation", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(doesContactExistInWorkspace).mockResolvedValue(true);
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should throw original error if doesContactExistInWorkspace fails", async () => {
|
||||
test("should throw original error if doesContactExist fails", async () => {
|
||||
const contactCheckError = new Error("Failed to check contact");
|
||||
vi.mocked(doesContactExistInWorkspace).mockRejectedValue(contactCheckError);
|
||||
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[workspaceId]/displays/types/display";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { doesContactExistInWorkspace } from "./contact";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
const { contactId, surveyId, workspaceId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExistInWorkspace(contactId, workspaceId) : false;
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -49,7 +49,18 @@ const buildPrismaResponseData = (
|
||||
contact: { id: string; attributes: TContactAttributes } | null,
|
||||
ttc: Record<string, number>
|
||||
): Prisma.ResponseCreateInput => {
|
||||
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
return {
|
||||
survey: {
|
||||
@@ -73,6 +84,8 @@ const buildPrismaResponseData = (
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+7
-13
@@ -1602,15 +1602,13 @@ checksums:
|
||||
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
|
||||
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
|
||||
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
|
||||
workspace/analysis/charts/ai_enable_in_settings: 426cb4525381e193e6c4dcce286e60c8
|
||||
workspace/analysis/charts/ai_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
|
||||
workspace/analysis/charts/ai_not_available: 173abfcd32dd45edcc258dfdaaed494b
|
||||
workspace/analysis/charts/ai_not_enabled: 2066fe71ecf8994ba738c79b63a1934b
|
||||
workspace/analysis/charts/ai_not_in_plan: 4b75e143c97d657bd91f857ff2bbf33f
|
||||
workspace/analysis/charts/ai_not_enabled: 8651fdac58cd311d17a48001a880318d
|
||||
workspace/analysis/charts/ai_not_in_plan: 60bb0792a1ed98c07d8694029cdfdb43
|
||||
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
|
||||
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
|
||||
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
|
||||
workspace/analysis/charts/ai_upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
|
||||
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
|
||||
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
|
||||
@@ -2486,19 +2484,16 @@ checksums:
|
||||
workspace/settings/feedback_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_directories/no_access: 707627df25fbaa28f18aa0f0d03dcb81
|
||||
workspace/settings/feedback_directories/no_connectors: ccc725ff9a82a7b8ab68de735490a9b9
|
||||
workspace/settings/feedback_directories/no_unassigned_workspaces_description: c96a260b582e6c930de72e6e69f9a9a6
|
||||
workspace/settings/feedback_directories/no_unassigned_workspaces_title: 458d4289d73d799561bec26a0bb1a1a3
|
||||
workspace/settings/feedback_directories/pause_connectors_confirmation_description: 0e30f827576b931651b9eae44e00279b
|
||||
workspace/settings/feedback_directories/pause_connectors_confirmation_title: da1950dbb9ce62caa65c87ae8b88b1a1
|
||||
workspace/settings/feedback_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_directories/title: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
workspace/settings/feedback_directories/unarchive_workspace_conflict: ed44bc0bd570b40de5251d04abf7bd08
|
||||
workspace/settings/feedback_directories/unarchive_workspace_conflict: 82f4b8ebaf41589cfb96e6398dafcc76
|
||||
workspace/settings/feedback_directories/upgrade_prompt_description: eb8a4bf60bcae458899e1ea94094789d
|
||||
workspace/settings/feedback_directories/upgrade_prompt_title: 0a7b67ccf15a0aa8c64e5da7feb6e532
|
||||
workspace/settings/feedback_directories/workspace_access: 32407b39cf878fb579559c1ed3660892
|
||||
workspace/settings/feedback_directories/workspace_assigned_to_directory: 6b907668667a9c74a99c437fa3cc2046
|
||||
workspace/settings/feedback_directories/workspaces_already_linked: ef6248289707611a44950c3406aec0ec
|
||||
workspace/settings/feedback_directories/workspaces_being_added: e01628710aff05c5172f2f43aab1f6fb
|
||||
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
@@ -2592,6 +2587,7 @@ checksums:
|
||||
workspace/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
|
||||
workspace/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
|
||||
workspace/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
|
||||
workspace/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
|
||||
workspace/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
|
||||
workspace/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
|
||||
workspace/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
|
||||
@@ -2602,8 +2598,8 @@ checksums:
|
||||
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
|
||||
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
|
||||
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
|
||||
workspace/settings/profile/sso_identity_confirmation_failed: 2d699f31f3e92bca9508a2772b071a1f
|
||||
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: 9a5d190ed96e0149ed431c130c40284d
|
||||
workspace/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
|
||||
workspace/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
|
||||
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
|
||||
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
|
||||
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
|
||||
@@ -2906,7 +2902,6 @@ checksums:
|
||||
workspace/surveys/edit/hidden_field_used_in_recall: 15d959528c3e817dce95640173d5d6a8
|
||||
workspace/surveys/edit/hidden_field_used_in_recall_ending_card: ea0d0b12ca1c9400690658cb1b537025
|
||||
workspace/surveys/edit/hidden_field_used_in_recall_welcome: bb498b6ee69c6311a3977d454866b610
|
||||
workspace/surveys/edit/hidden_fields_description: e9221cd00ae2944602c19ffbc82358a4
|
||||
workspace/surveys/edit/hide_back_button: 91355864b3032c3f57689074e2173544
|
||||
workspace/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
|
||||
workspace/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
|
||||
@@ -3208,7 +3203,6 @@ checksums:
|
||||
workspace/surveys/edit/variable_used_in_recall: 1979c231569117297d1a19972b349617
|
||||
workspace/surveys/edit/variable_used_in_recall_ending_card: e6ab9a124985708dd77067c014b7c514
|
||||
workspace/surveys/edit/variable_used_in_recall_welcome: 60b995389b488366d8f6f53df35b6d8d
|
||||
workspace/surveys/edit/variables_description: 4a55faa279acc675228f54dccf63db6a
|
||||
workspace/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
@@ -3452,6 +3446,7 @@ checksums:
|
||||
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
|
||||
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
|
||||
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
@@ -3520,7 +3515,6 @@ checksums:
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/api_ingestion_setup_description: d18a267d0e50198682950f5341307fa3
|
||||
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
assertOrganizationAIConfigured,
|
||||
generateOrganizationAIText,
|
||||
getAIDataAnalysisUnavailableReason,
|
||||
getAISmartToolsUnavailableReason,
|
||||
getOrganizationAIConfig,
|
||||
isInstanceAIConfigured,
|
||||
} from "./service";
|
||||
@@ -208,47 +207,4 @@ describe("AI organization service", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAISmartToolsUnavailableReason", () => {
|
||||
const baseConfig = {
|
||||
organizationId: "org_1",
|
||||
isAISmartToolsEntitled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEntitled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
isInstanceConfigured: true,
|
||||
};
|
||||
|
||||
test("returns undefined when all checks pass", () => {
|
||||
expect(getAISmartToolsUnavailableReason(baseConfig)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns not_in_plan when smart tools entitlement is missing", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEntitled: false })).toBe(
|
||||
"not_in_plan"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns not_enabled when smart tools is disabled at org level", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isAISmartToolsEnabled: false })).toBe(
|
||||
"not_enabled"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns instance_not_configured when instance AI is missing", () => {
|
||||
expect(getAISmartToolsUnavailableReason({ ...baseConfig, isInstanceConfigured: false })).toBe(
|
||||
"instance_not_configured"
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores data-analysis flags (smart tools is independent of data analysis state)", () => {
|
||||
expect(
|
||||
getAISmartToolsUnavailableReason({
|
||||
...baseConfig,
|
||||
isAIDataAnalysisEntitled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,15 +59,6 @@ export const getAIDataAnalysisUnavailableReason = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getAISmartToolsUnavailableReason = (
|
||||
aiConfig: TOrganizationAIConfig
|
||||
): TAIUnavailableReason | undefined => {
|
||||
if (!aiConfig.isAISmartToolsEntitled) return "not_in_plan";
|
||||
if (!aiConfig.isAISmartToolsEnabled) return "not_enabled";
|
||||
if (!aiConfig.isInstanceConfigured) return "instance_not_configured";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const assertOrganizationAIConfigured = async (
|
||||
organizationId: string,
|
||||
capability: "smartTools" | "dataAnalysis"
|
||||
|
||||
@@ -25,8 +25,7 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
|
||||
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
@@ -34,6 +33,7 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
|
||||
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
|
||||
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
|
||||
|
||||
+4
-2
@@ -153,7 +153,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(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
|
||||
@@ -173,6 +173,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GOOGLE_CLOUD_PROJECT: z.string().optional(),
|
||||
@@ -314,7 +315,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,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
@@ -332,6 +333,7 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
|
||||
|
||||
@@ -1126,7 +1126,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("account deletion SSO identity confirmation intents", () => {
|
||||
describe("account deletion SSO reauthentication intents", () => {
|
||||
const accountDeletionIntent = {
|
||||
id: "intent-id",
|
||||
userId: mockUser.id,
|
||||
@@ -1137,7 +1137,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
|
||||
};
|
||||
|
||||
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
|
||||
test("round-trips encrypted account deletion reauth intents", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
|
||||
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
|
||||
@@ -1154,14 +1154,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("creates account deletion SSO identity confirmation intents with a ten minute default expiry", () => {
|
||||
test("creates account deletion reauth intents with a ten minute default expiry", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
|
||||
expect(decoded.exp - decoded.iat).toBe(10 * 60);
|
||||
});
|
||||
|
||||
test("rejects account deletion SSO identity confirmation intents with the wrong purpose", () => {
|
||||
test("rejects account deletion reauth intents with the wrong purpose", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1183,7 +1183,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
|
||||
test("rejects account deletion reauth intents missing required fields", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1204,7 +1204,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects expired account deletion SSO identity confirmation intents", () => {
|
||||
test("rejects expired account deletion reauth intents", () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
@@ -1225,7 +1225,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
|
||||
});
|
||||
|
||||
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
|
||||
test("throws when account deletion reauth intent secrets are missing", async () => {
|
||||
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
|
||||
|
||||
const token = jwt.sign(
|
||||
|
||||
@@ -216,6 +216,43 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
|
||||
}
|
||||
});
|
||||
|
||||
export const getResponseWithQuotas = reactCache(
|
||||
async (responseId: string): Promise<TResponseWithQuotas | null> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
|
||||
try {
|
||||
const responsePrisma = await prisma.response.findUnique({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
select: {
|
||||
...responseSelection,
|
||||
quotaLinks: {
|
||||
where: { status: "screenedIn" },
|
||||
include: { quota: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { quotaLinks, ...rest } = responsePrisma;
|
||||
return {
|
||||
...mapResponsePrismaToResponse(rest),
|
||||
quotas: quotaLinks.map((ql) => ql.quota),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
|
||||
validateInputs([responseId, ZId]);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getResponseBySingleUseId,
|
||||
getResponseCountBySurveyId,
|
||||
getResponseDownloadFile,
|
||||
getResponseWithQuotas,
|
||||
getResponsesByWorkspaceId,
|
||||
responseSelection,
|
||||
updateResponse,
|
||||
@@ -170,6 +171,70 @@ describe("Tests for getResponse service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getResponseWithQuotas service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Returns the response with screened-in quotas", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
|
||||
|
||||
expect(result).toEqual({
|
||||
...expectedResponseWithoutPerson,
|
||||
quotas: mockResponseWithQuotas.quotaLinks.map(
|
||||
(ql: { quota: { id: string; name: string } }) => ql.quota
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponse.id);
|
||||
|
||||
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
|
||||
});
|
||||
|
||||
test("Selects only screened-in quotaLinks", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
|
||||
|
||||
await getResponseWithQuotas(mockResponse.id);
|
||||
|
||||
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
|
||||
expect(findUniqueCall?.select?.quotaLinks).toEqual({
|
||||
where: { status: "screenedIn" },
|
||||
include: { quota: { select: { id: true, name: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponseWithQuotas, "123#");
|
||||
|
||||
test("Returns null when no response is found", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getResponseWithQuotas(mockResponse.id);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.response.findUnique.mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Rethrows generic errors", async () => {
|
||||
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSurveySummary service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Returns a summary of the survey responses", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ConfigurationError,
|
||||
EXPECTED_ERROR_NAMES,
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidInputError,
|
||||
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"ValidationError",
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"ConfigurationError",
|
||||
"QueryExecutionError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
@@ -94,6 +96,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: ConfigurationError, args: ["Cube is not configured"] },
|
||||
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
@@ -185,6 +188,12 @@ describe("actionClient handleServerError", () => {
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ConfigurationError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(new ConfigurationError("Cube is not configured"));
|
||||
expect(result?.serverError).toBe("Cube is not configured");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("QueryExecutionError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new QueryExecutionError("Cube query failed. Details: connect ECONNREFUSED")
|
||||
|
||||
@@ -38,50 +38,6 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -104,54 +60,4 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,30 +2,11 @@ import { AsyncParser } from "@json2csv/node";
|
||||
import * as xlsx from "xlsx";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Defang spreadsheet formula injection. Cell values starting with
|
||||
// =, +, -, @, tab, or CR are evaluated as formulas by Excel/Sheets/Numbers.
|
||||
// Sanitize at the render boundary only — never rewrite row keys, since
|
||||
// distinct user-controlled labels could collide after prefixing (e.g.
|
||||
// "=field" and "'=field" both map to "'=field"), dropping cell data.
|
||||
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
|
||||
|
||||
const sanitizeFormulaInjection = <T>(value: T): T => {
|
||||
if (typeof value === "string" && FORMULA_TRIGGER.test(value)) {
|
||||
return `'${value}` as T;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const convertToCsv = async (fields: string[], jsonData: Record<string, string | number>[]) => {
|
||||
let csv: string = "";
|
||||
|
||||
// Field descriptors preserve the original lookup key while overriding the
|
||||
// rendered label and cell value with sanitized versions.
|
||||
const parser = new AsyncParser({
|
||||
fields: fields.map((name) => ({
|
||||
label: sanitizeFormulaInjection(name),
|
||||
value: (row: Record<string, string | number>) => sanitizeFormulaInjection(row[name]),
|
||||
})),
|
||||
fields,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -42,13 +23,8 @@ export const convertToXlsxBuffer = (
|
||||
fields: string[],
|
||||
jsonData: Record<string, string | number>[]
|
||||
): Buffer => {
|
||||
// Build as array-of-arrays so original row keys are looked up before
|
||||
// sanitization is applied to the rendered header/cell only.
|
||||
const headerRow = fields.map(sanitizeFormulaInjection);
|
||||
const dataRows = jsonData.map((row) => fields.map((name) => sanitizeFormulaInjection(row[name])));
|
||||
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.aoa_to_sheet([headerRow, ...dataRows]);
|
||||
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
};
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Filter hinzufügen",
|
||||
"add_to_dashboard": "Zum Dashboard hinzufügen",
|
||||
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
|
||||
"ai_enable_in_settings": "Aktiviere es in den Organisationseinstellungen.",
|
||||
"ai_instance_not_configured": "KI ist auf dieser Instanz nicht konfiguriert. Kontaktiere deinen Administrator.",
|
||||
"ai_not_available": "KI-Datenanalyse ist nicht verfügbar.",
|
||||
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert.",
|
||||
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Plan nicht verfügbar.",
|
||||
"ai_not_enabled": "KI-Datenanalyse ist für diese Organisation deaktiviert. Aktiviere sie in den Organisationseinstellungen.",
|
||||
"ai_not_in_plan": "KI-Datenanalyse ist in deinem aktuellen Tarif nicht verfügbar. Führe ein Upgrade durch, um diese Funktion freizuschalten.",
|
||||
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
|
||||
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
|
||||
"ai_query_section_title": "Frag deine Daten",
|
||||
"ai_upgrade_plan": "Plan upgraden",
|
||||
"already_on_dashboard": "Bereits im Dashboard",
|
||||
"and_filter_logic": "UND",
|
||||
"apply_changes": "Änderungen übernehmen",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Feedback-Verzeichnisse",
|
||||
"no_access": "Du hast keine Berechtigung, Feedback-Verzeichnisse zu verwalten.",
|
||||
"no_connectors": "Noch keine Feedback-Quellen mit diesem Verzeichnis verknüpft.",
|
||||
"no_unassigned_workspaces_description": "Jeder Workspace ist bereits mit einem aktiven Feedback-Verzeichnis verknüpft. Entferne einen Workspace aus seinem aktuellen Verzeichnis, bevor du ihn hier zuweist.",
|
||||
"no_unassigned_workspaces_title": "Keine nicht zugewiesenen Workspaces verfügbar",
|
||||
"pause_connectors_confirmation_description": "Wenn du diese Feedback-Quellen pausierst, werden keine neuen Einträge mehr hinzugefügt.",
|
||||
"pause_connectors_confirmation_title": "Verknüpfte Feedback-Quellen pausieren?",
|
||||
"select_workspaces_placeholder": "Workspaces auswählen...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organisiere Feedback-Datensätze in Verzeichnissen und leite Daten zum richtigen Workspace weiter. Verfügbar in den Pro- und Scale-Plänen.",
|
||||
"upgrade_prompt_title": "Upgrade durchführen, um Feedback-Datensatz-Verzeichnisse freizuschalten",
|
||||
"workspace_access": "Workspace-Zugriff",
|
||||
"workspace_assigned_to_directory": "{workspaceName} ist mit {directoryName} verknüpft",
|
||||
"workspaces_already_linked": "Bereits verknüpfte Workspaces",
|
||||
"workspaces_being_added": "Workspaces, denen Zugriff gewährt wird"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "E-Mail-Bestätigung stimmt nicht überein.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib unten den Code aus deiner Authentifizierungs-App ein.",
|
||||
"google_sso_account_deletion_requires_setup": "Wir konnten deine Identität nicht mit deinem SSO-Anbieter bestätigen. Bitte versuche es erneut oder kontaktiere deinen Administrator.",
|
||||
"lost_access": "Zugang verloren",
|
||||
"or_enter_the_following_code_manually": "Oder gib folgenden Code manuell ein:",
|
||||
"organizations_delete_message": "Du bist der einzige Inhaber dieser Organisationen, daher <b>werden sie ebenfalls gelöscht.</b>",
|
||||
@@ -2715,8 +2711,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 Authenticator-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie die 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 die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
|
||||
"sso_reauthentication_failed": "SSO-Neuauthentifizierung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von „Löschen“ zu einer Weiterleitung zu deinem Identitätsanbieter führen. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
|
||||
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"two_factor_authentication_description": "Füge deinem Konto eine zusätzliche Sicherheitsebene 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 Authenticator-App ein.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Verborgenes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"hidden_field_used_in_recall_ending_card": "Verborgenes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen",
|
||||
"hidden_field_used_in_recall_welcome": "Verborgenes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
|
||||
"hidden_fields_description": "Übergib versteckte Daten an deine Umfrage, ohne sie den Teilnehmern zu zeigen.",
|
||||
"hide_back_button": "\"Zurück\"-Button ausblenden",
|
||||
"hide_back_button_description": "Zeige den Zurück-Button in der Umfrage nicht an",
|
||||
"hide_block_settings": "Block-Einstellungen ausblenden",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Die Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"variable_used_in_recall_ending_card": "Die Variable {variable} wird in der Abschlusskarte abgerufen",
|
||||
"variable_used_in_recall_welcome": "Die Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
|
||||
"variables_description": "Definiere und berechne Werte während deiner Umfrage.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
|
||||
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
|
||||
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
|
||||
"open_response_details": "Details zu offenen Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Gesamt",
|
||||
"promoters": "Promotoren",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Zulässige Werte: {values}",
|
||||
"api_ingestion": "API-Erfassung",
|
||||
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
|
||||
"api_ingestion_setup_description": "Nutze die REST API, um Feedback-Datensätze direkt an Formbricks zu senden. Die API-Ingestion-Docs enthalten den Endpunkt, die Payload-Struktur und Authentifizierungsdetails.",
|
||||
"auto_generated": "Automatisch generiert",
|
||||
"change_file": "Datei ändern",
|
||||
"clear_mapping": "Zuordnung löschen",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Add filter",
|
||||
"add_to_dashboard": "Add to Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configure your chart and click \"Run Query\" to preview",
|
||||
"ai_enable_in_settings": "Enable it in organization settings.",
|
||||
"ai_instance_not_configured": "AI is not configured on this instance. Contact your administrator.",
|
||||
"ai_not_available": "AI data analysis is not available.",
|
||||
"ai_not_enabled": "AI data analysis is disabled for this organization.",
|
||||
"ai_not_in_plan": "AI data analysis is not available on your current plan.",
|
||||
"ai_not_enabled": "AI data analysis is disabled for this organization. Enable it in organization settings.",
|
||||
"ai_not_in_plan": "AI data analysis is not available on your current plan. Upgrade to unlock this feature.",
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"ai_upgrade_plan": "Upgrade plan",
|
||||
"already_on_dashboard": "Already on dashboard",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "Apply Changes",
|
||||
@@ -2593,19 +2591,16 @@
|
||||
"nav_label": "Feedback Directories",
|
||||
"no_access": "You do not have permission to manage feedback directories.",
|
||||
"no_connectors": "No feedback sources linked to this directory yet.",
|
||||
"no_unassigned_workspaces_description": "Every workspace is already linked to an active feedback directory. Remove a workspace from its current directory before assigning it here.",
|
||||
"no_unassigned_workspaces_title": "No unassigned workspaces available",
|
||||
"pause_connectors_confirmation_description": "Pausing these feedback sources will stop new records from being added.",
|
||||
"pause_connectors_confirmation_title": "Pause linked feedback sources?",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Directories",
|
||||
"unarchive": "Unarchive",
|
||||
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces already belong to another active feedback directory.",
|
||||
"unarchive_workspace_conflict": "Cannot unarchive this directory because one or more assigned workspaces are archived.",
|
||||
"upgrade_prompt_description": "Organize feedback records into directories and route data to the right workspace. Available on the Pro and Scale plans.",
|
||||
"upgrade_prompt_title": "Upgrade to unlock Feedback Directories",
|
||||
"workspace_access": "Workspace access",
|
||||
"workspace_assigned_to_directory": "{workspaceName} is linked to {directoryName}",
|
||||
"workspaces_already_linked": "Already linked workspaces",
|
||||
"workspaces_being_added": "Workspaces being granted access"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Lost access",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
@@ -2715,8 +2711,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.",
|
||||
"sso_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted automatically.",
|
||||
"two_factor_authentication": "Two factor authentication",
|
||||
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Hidden field “{hiddenField}” is being recalled in question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Hidden field “{hiddenField}” is being recalled in Ending Card",
|
||||
"hidden_field_used_in_recall_welcome": "Hidden field “{hiddenField}” is being recalled in Welcome card.",
|
||||
"hidden_fields_description": "Pass hidden data into your survey without showing it to respondents.",
|
||||
"hide_back_button": "Hide “Back” button",
|
||||
"hide_back_button_description": "Do not display the back button in the survey",
|
||||
"hide_block_settings": "Hide Block settings",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variable “{variable}” is being recalled in question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
|
||||
"variable_used_in_recall_welcome": "Variable “{variable}” is being recalled in Welcome Card.",
|
||||
"variables_description": "Define and compute values throughout your survey.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
"verify_email_before_submission_description": "Only let people with a real email respond.",
|
||||
"visibility_and_recontact": "Visibility & Recontact",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "No impressions from identified contacts",
|
||||
"no_responses_found": "No responses found",
|
||||
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
|
||||
"open_response_details": "Open response details",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"promoters": "Promoters",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Allowed values: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"api_ingestion_setup_description": "Use the REST API to send feedback records directly into Formbricks. The API ingestion docs include the endpoint, payload shape, and authentication details.",
|
||||
"auto_generated": "Auto-generated",
|
||||
"change_file": "Change file",
|
||||
"clear_mapping": "Clear mapping",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Añadir filtro",
|
||||
"add_to_dashboard": "Añadir al panel de control",
|
||||
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
|
||||
"ai_enable_in_settings": "Actívalo en la configuración de la organización.",
|
||||
"ai_instance_not_configured": "La IA no está configurada en esta instancia. Contacta con tu administrador.",
|
||||
"ai_not_available": "El análisis de datos con IA no está disponible.",
|
||||
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización.",
|
||||
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual.",
|
||||
"ai_not_enabled": "El análisis de datos con IA está desactivado para esta organización. Actívalo en la configuración de la organización.",
|
||||
"ai_not_in_plan": "El análisis de datos con IA no está disponible en tu plan actual. Actualiza para desbloquear esta función.",
|
||||
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
|
||||
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
|
||||
"ai_query_section_title": "Pregunta a tus datos",
|
||||
"ai_upgrade_plan": "Mejorar plan",
|
||||
"already_on_dashboard": "Ya está en el panel",
|
||||
"and_filter_logic": "Y",
|
||||
"apply_changes": "Aplicar cambios",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Directorios de Feedback",
|
||||
"no_access": "No tienes permiso para gestionar directorios de feedback.",
|
||||
"no_connectors": "Aún no hay fuentes de comentarios vinculadas a este directorio.",
|
||||
"no_unassigned_workspaces_description": "Cada espacio de trabajo ya está vinculado a un directorio de feedback activo. Elimina un espacio de trabajo de su directorio actual antes de asignarlo aquí.",
|
||||
"no_unassigned_workspaces_title": "No hay espacios de trabajo sin asignar disponibles",
|
||||
"pause_connectors_confirmation_description": "Pausar estas fuentes de comentarios detendrá la adición de nuevos registros.",
|
||||
"pause_connectors_confirmation_title": "¿Pausar las fuentes de comentarios vinculadas?",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organiza los registros de feedback en directorios y dirige los datos al espacio de trabajo adecuado. Disponible en los planes Pro y Scale.",
|
||||
"upgrade_prompt_title": "Mejora tu plan para desbloquear los Directorios de Registros de Feedback",
|
||||
"workspace_access": "Acceso al espacio de trabajo",
|
||||
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
|
||||
"workspaces_already_linked": "Espacios de trabajo ya vinculados",
|
||||
"workspaces_being_added": "Espacios de trabajo a los que se concede acceso"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activar autenticación de dos factores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acceso perdido",
|
||||
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
|
||||
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
|
||||
@@ -2715,8 +2711,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": "La confirmación de identidad SSO ha fallado. Por favor, intenta eliminar tu cuenta de nuevo.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Para cuentas con 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 continúa automáticamente.",
|
||||
"sso_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará automáticamente.",
|
||||
"two_factor_authentication": "Autenticación de dos factores",
|
||||
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "El campo oculto \"{hiddenField}\" se está recordando en la pregunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta final.",
|
||||
"hidden_field_used_in_recall_welcome": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta de bienvenida.",
|
||||
"hidden_fields_description": "Pasa datos ocultos a tu encuesta sin mostrárselos a los encuestados.",
|
||||
"hide_back_button": "Ocultar botón 'Atrás'",
|
||||
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
|
||||
"hide_block_settings": "Ocultar ajustes del bloque",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "La variable {variable} se está recuperando en la tarjeta final",
|
||||
"variable_used_in_recall_welcome": "La variable \"{variable}\" se está recuperando en la tarjeta de bienvenida.",
|
||||
"variables_description": "Define y calcula valores a lo largo de tu encuesta.",
|
||||
"verify_email_before_submission": "Verificar correo electrónico antes del envío",
|
||||
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
|
||||
"visibility_and_recontact": "Visibilidad y recontacto",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "No hay impresiones de contactos identificados",
|
||||
"no_responses_found": "No se han encontrado respuestas",
|
||||
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
|
||||
"open_response_details": "Detalles de respuesta abierta",
|
||||
"other_values_found": "Otros valores encontrados",
|
||||
"overall": "General",
|
||||
"promoters": "Promotores",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingesta de API",
|
||||
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
|
||||
"api_ingestion_setup_description": "Utiliza la API REST para enviar registros de feedback directamente a Formbricks. La documentación de ingesta de API incluye el endpoint, la estructura del payload y los detalles de autenticación.",
|
||||
"auto_generated": "Generado automáticamente",
|
||||
"change_file": "Cambiar archivo",
|
||||
"clear_mapping": "Borrar asignación",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Ajouter un filtre",
|
||||
"add_to_dashboard": "Ajouter au tableau de bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur « Exécuter la requête » pour prévisualiser",
|
||||
"ai_enable_in_settings": "Activez-la dans les paramètres de l'organisation.",
|
||||
"ai_instance_not_configured": "L'IA n'est pas configurée sur cette instance. Contacte ton administrateur.",
|
||||
"ai_not_available": "L'analyse de données par IA n'est pas disponible.",
|
||||
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation.",
|
||||
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible avec ton abonnement actuel.",
|
||||
"ai_not_enabled": "L'analyse de données par IA est désactivée pour cette organisation. Active-la dans les paramètres de l'organisation.",
|
||||
"ai_not_in_plan": "L'analyse de données par IA n'est pas disponible sur ton forfait actuel. Passe à un forfait supérieur pour débloquer cette fonctionnalité.",
|
||||
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière ?",
|
||||
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
|
||||
"ai_query_section_title": "Interrogez vos données",
|
||||
"ai_upgrade_plan": "Mettre à niveau l'abonnement",
|
||||
"already_on_dashboard": "Déjà sur le tableau de bord",
|
||||
"and_filter_logic": "ET",
|
||||
"apply_changes": "Appliquer les modifications",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Répertoires de feedback",
|
||||
"no_access": "Tu n'as pas la permission de gérer les répertoires de retours.",
|
||||
"no_connectors": "Aucune source de retours liée à ce répertoire pour le moment.",
|
||||
"no_unassigned_workspaces_description": "Chaque espace de travail est déjà lié à un répertoire de commentaires actif. Retirez un espace de travail de son répertoire actuel avant de l'assigner ici.",
|
||||
"no_unassigned_workspaces_title": "Aucun espace de travail non assigné disponible",
|
||||
"pause_connectors_confirmation_description": "Mettre en pause ces sources de retours empêchera l'ajout de nouveaux enregistrements.",
|
||||
"pause_connectors_confirmation_title": "Mettre en pause les sources de retours liées ?",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organisez les enregistrements de feedback dans des répertoires et dirigez les données vers le bon espace de travail. Disponible avec les forfaits Pro et Scale.",
|
||||
"upgrade_prompt_title": "Passez à un forfait supérieur pour débloquer les Répertoires d'enregistrements de feedback",
|
||||
"workspace_access": "Accès à l’espace de travail",
|
||||
"workspace_assigned_to_directory": "{workspaceName} est lié à {directoryName}",
|
||||
"workspaces_already_linked": "Espaces de travail déjà liés",
|
||||
"workspaces_being_added": "Espaces de travail en cours d'ajout"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Accès perdu",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
@@ -2715,8 +2711,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 de l'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 te rediriger vers ton fournisseur d'identité pour confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
|
||||
"sso_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé automatiquement.",
|
||||
"two_factor_authentication": "Authentification à deux facteurs",
|
||||
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
|
||||
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
|
||||
"hidden_fields_description": "Transmets des données masquées dans ton questionnaire sans les montrer aux répondants.",
|
||||
"hide_back_button": "Masquer le bouton 'Retour'",
|
||||
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
|
||||
"hide_block_settings": "Masquer les paramètres du bloc",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
|
||||
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
|
||||
"variables_description": "Définis et calcule des valeurs tout au long de ton questionnaire.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
|
||||
"visibility_and_recontact": "Visibilité et recontact",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Aucune impression des contacts identifiés",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
|
||||
"open_response_details": "Détails des réponses ouvertes",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"promoters": "Promoteurs",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Valeurs autorisées : {values}",
|
||||
"api_ingestion": "Ingestion par API",
|
||||
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
|
||||
"api_ingestion_setup_description": "Utilise l'API REST pour envoyer directement les retours d'expérience dans Formbricks. La documentation sur l'ingestion API inclut le point de terminaison, la structure de la charge utile et les détails d'authentification.",
|
||||
"auto_generated": "Généré automatiquement",
|
||||
"change_file": "Changer de fichier",
|
||||
"clear_mapping": "Effacer le mappage",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Szűrő hozzáadása",
|
||||
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
|
||||
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
|
||||
"ai_enable_in_settings": "Engedélyezze a szervezeti beállításokban.",
|
||||
"ai_instance_not_configured": "Az AI nincs konfigurálva ezen a példányon. Kérjük, lépjen kapcsolatba a rendszergazdával.",
|
||||
"ai_not_available": "Az AI adatelemzés nem elérhető.",
|
||||
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára.",
|
||||
"ai_not_in_plan": "Az AI adatelemzés nem érhető el az Ön jelenlegi csomagjában.",
|
||||
"ai_not_enabled": "Az AI adatelemzés le van tiltva ezen szervezet számára. Kérjük, engedélyezze a szervezeti beállításokban.",
|
||||
"ai_not_in_plan": "Az AI adatelemzés nem elérhető az Ön jelenlegi csomagjában. Kérjük, frissítsen magasabb csomagra ezen funkció feloldásához.",
|
||||
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
|
||||
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
|
||||
"ai_query_section_title": "Kérdezd meg az adataidat",
|
||||
"ai_upgrade_plan": "Csomag frissítése",
|
||||
"already_on_dashboard": "Már a vezérlőpulton van",
|
||||
"and_filter_logic": "ÉS",
|
||||
"apply_changes": "Módosítások alkalmazása",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Visszajelzési könyvtárak",
|
||||
"no_access": "Önnek nincs jogosultsága a visszajelzési könyvtárak kezeléséhez.",
|
||||
"no_connectors": "Még nincsenek visszajelzési források kapcsolva ehhez a könyvtárhoz.",
|
||||
"no_unassigned_workspaces_description": "Minden munkaterület már hozzá van rendelve egy aktív visszajelzési könyvtárhoz. Távolítson el egy munkaterületet a jelenlegi könyvtárából, mielőtt ide rendelné.",
|
||||
"no_unassigned_workspaces_title": "Nincsenek hozzá nem rendelt munkaterületek",
|
||||
"pause_connectors_confirmation_description": "Ezen visszajelzési források szüneteltetése megállítja az új rekordok hozzáadását.",
|
||||
"pause_connectors_confirmation_title": "Szünetelteti a kapcsolódó visszajelzési forrásokat?",
|
||||
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Szervezze a visszajelzési rekordokat könyvtárakba, és irányítsa az adatokat a megfelelő munkaterületre. A Pro és Scale csomagokban érhető el.",
|
||||
"upgrade_prompt_title": "Frissítsen a csomagon, hogy feloldja a Visszajelzési Rekord Könyvtárakat",
|
||||
"workspace_access": "Munkaterület-hozzáférés",
|
||||
"workspace_assigned_to_directory": "{workspaceName} hozzá van rendelve ehhez: {directoryName}",
|
||||
"workspaces_already_linked": "Már kapcsolt munkaterületek",
|
||||
"workspaces_being_added": "Hozzáférést kapó munkaterületek"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
|
||||
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
|
||||
"google_sso_account_deletion_requires_setup": "Nem tudtuk megerősíteni a személyazonosságát az SSO-szolgáltatóval. Próbálja meg újra, vagy vegye fel a kapcsolatot az adminisztrátorral.",
|
||||
"lost_access": "Elvesztett hozzáférés",
|
||||
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
|
||||
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
|
||||
@@ -2715,8 +2711,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 sikertelen volt. Kérjük, próbálja meg újra törölni fiókját.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés gomb kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatójához a fiók megerősítése érdekében. Ha ugyanazt a fiókot erősíti meg, a törlés automatikusan folytatódik.",
|
||||
"sso_reauthentication_failed": "Az SSO újrahitelesítése nem sikerült. Próbálja meg újra törölni a fiókját.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatójához. Ha a személyazonossága megerősítésre került, akkor a fiókja automatikusan törölve lesz.",
|
||||
"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.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a(z) {questionIndex}. kérdésben.",
|
||||
"hidden_field_used_in_recall_ending_card": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a befejező kártyában",
|
||||
"hidden_field_used_in_recall_welcome": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül az üdvözlő kártyában.",
|
||||
"hidden_fields_description": "Rejtett adatokat továbbíthat a felmérésbe anélkül, hogy azokat a válaszadók látnák.",
|
||||
"hide_back_button": "A „Vissza” gomb elrejtése",
|
||||
"hide_back_button_description": "Ne jelenjen meg a vissza gomb a kérdőívben",
|
||||
"hide_block_settings": "Blokkbeállítások elrejtése",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "A(z) „{variable}” változó visszahívásra kerül a(z) {questionIndex}. kérdésben.",
|
||||
"variable_used_in_recall_ending_card": "A(z) {variable} változó visszahívásra kerül a befejező kártyában",
|
||||
"variable_used_in_recall_welcome": "A(z) „{variable}” változó visszahívásra kerül az üdvözlő kártyában.",
|
||||
"variables_description": "Értékeket határozhat meg és számíthat ki a felmérés során.",
|
||||
"verify_email_before_submission": "E-mail-cím ellenőrzése a beküldés előtt",
|
||||
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
|
||||
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
|
||||
"no_responses_found": "Nem találhatók válaszok",
|
||||
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
|
||||
"open_response_details": "Nyitott válasz részletei",
|
||||
"other_values_found": "Más értékek találhatók",
|
||||
"overall": "Összesen",
|
||||
"promoters": "Népszerűsítők",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Engedélyezett értékek: {values}",
|
||||
"api_ingestion": "API betöltés",
|
||||
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
|
||||
"api_ingestion_setup_description": "Használja a REST API-t, hogy közvetlenül küldjön visszajelzési rekordokat a Formbricks rendszerbe. Az API-betöltési dokumentáció tartalmazza a végpontot, az adatszerkezetet és a hitelesítési részleteket.",
|
||||
"auto_generated": "Automatikusan generált",
|
||||
"change_file": "Fájl módosítása",
|
||||
"clear_mapping": "Leképezés törlése",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "フィルターを追加",
|
||||
"add_to_dashboard": "ダッシュボードに追加",
|
||||
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
|
||||
"ai_enable_in_settings": "組織設定で有効にしてください。",
|
||||
"ai_instance_not_configured": "このインスタンスではAIが設定されていません。管理者にお問い合わせください。",
|
||||
"ai_not_available": "AIデータ分析は利用できません。",
|
||||
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。",
|
||||
"ai_not_in_plan": "現在のプランではAIデータ分析をご利用いただけません。",
|
||||
"ai_not_enabled": "この組織ではAIデータ分析が無効になっています。組織設定で有効にしてください。",
|
||||
"ai_not_in_plan": "AIデータ分析は現在のプランではご利用いただけません。この機能を利用するにはアップグレードしてください。",
|
||||
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
|
||||
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
|
||||
"ai_query_section_title": "データに質問する",
|
||||
"ai_upgrade_plan": "プランをアップグレード",
|
||||
"already_on_dashboard": "すでにダッシュボードに追加済み",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "変更を適用",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバックディレクトリを管理する権限がありません。",
|
||||
"no_connectors": "このディレクトリにリンクされたフィードバックソースがまだありません。",
|
||||
"no_unassigned_workspaces_description": "すべてのワークスペースは既にアクティブなフィードバックディレクトリにリンクされています。ここに割り当てる前に、現在のディレクトリからワークスペースを削除してください。",
|
||||
"no_unassigned_workspaces_title": "未割り当てのワークスペースがありません",
|
||||
"pause_connectors_confirmation_description": "これらのフィードバックソースを一時停止すると、新しいレコードの追加が停止されます。",
|
||||
"pause_connectors_confirmation_title": "リンクされたフィードバックソースを一時停止しますか?",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "フィードバックレコードをディレクトリで整理し、適切なワークスペースにデータを振り分けられます。ProプランおよびScaleプランでご利用いただけます。",
|
||||
"upgrade_prompt_title": "アップグレードしてフィードバックレコードディレクトリを利用",
|
||||
"workspace_access": "ワークスペースアクセス",
|
||||
"workspace_assigned_to_directory": "{workspaceName}は{directoryName}にリンクされています",
|
||||
"workspaces_already_linked": "既にリンクされているワークスペース",
|
||||
"workspaces_being_added": "アクセス権が付与されるワークスペース"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "二段階認証を有効にする",
|
||||
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "アクセスを紛失しましたか",
|
||||
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
|
||||
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
|
||||
@@ -2715,8 +2711,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プロバイダーにリダイレクトされる場合があります。同じアカウントが確認されると、削除が自動的に続行されます。",
|
||||
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試してください。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択するとIDプロバイダーにリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
|
||||
"two_factor_authentication": "二段階認証",
|
||||
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
|
||||
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"hidden_fields_description": "回答者に表示せずに、非表示データをアンケートに渡すことができます。",
|
||||
"hide_back_button": "「戻る」ボタンを非表示",
|
||||
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
|
||||
"hide_block_settings": "ブロック設定を非表示",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
|
||||
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"variables_description": "アンケート全体で値を定義し、計算できます。",
|
||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
|
||||
"visibility_and_recontact": "表示と再接触",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
|
||||
"open_response_details": "自由回答の詳細",
|
||||
"other_values_found": "他の値が見つかりました",
|
||||
"overall": "全体",
|
||||
"promoters": "推奨者",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "許可される値: {values}",
|
||||
"api_ingestion": "API取り込み",
|
||||
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
|
||||
"api_ingestion_setup_description": "REST APIを使用して、フィードバックレコードをFormbricksに直接送信できます。APIインジェストのドキュメントには、エンドポイント、ペイロード形式、認証の詳細が含まれています。",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "ファイルを変更",
|
||||
"clear_mapping": "マッピングをクリア",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Filter toevoegen",
|
||||
"add_to_dashboard": "Toevoegen aan dashboard",
|
||||
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
|
||||
"ai_enable_in_settings": "Schakel het in bij de organisatie-instellingen.",
|
||||
"ai_instance_not_configured": "AI is niet geconfigureerd op deze instantie. Neem contact op met je beheerder.",
|
||||
"ai_not_available": "AI-data-analyse is niet beschikbaar.",
|
||||
"ai_not_enabled": "AI-gegevensanalyse is uitgeschakeld voor deze organisatie.",
|
||||
"ai_not_in_plan": "AI-gegevensanalyse is niet beschikbaar in je huidige abonnement.",
|
||||
"ai_not_enabled": "AI-data-analyse is uitgeschakeld voor deze organisatie. Schakel het in bij de organisatie-instellingen.",
|
||||
"ai_not_in_plan": "AI-data-analyse is niet beschikbaar in je huidige abonnement. Upgrade om deze functie te ontgrendelen.",
|
||||
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
|
||||
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
|
||||
"ai_query_section_title": "Vraag het aan je data",
|
||||
"ai_upgrade_plan": "Abonnement upgraden",
|
||||
"already_on_dashboard": "Al op dashboard",
|
||||
"and_filter_logic": "EN",
|
||||
"apply_changes": "Wijzigingen toepassen",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackmappen te beheren.",
|
||||
"no_connectors": "Nog geen feedbackbronnen gekoppeld aan deze directory.",
|
||||
"no_unassigned_workspaces_description": "Elke workspace is al gekoppeld aan een actieve feedbackdirectory. Verwijder een workspace uit de huidige directory voordat je deze hier toewijst.",
|
||||
"no_unassigned_workspaces_title": "Geen niet-toegewezen workspaces beschikbaar",
|
||||
"pause_connectors_confirmation_description": "Het pauzeren van deze feedbackbronnen stopt het toevoegen van nieuwe gegevens.",
|
||||
"pause_connectors_confirmation_title": "Gekoppelde feedbackbronnen pauzeren?",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organiseer feedbackrecords in mappen en routeer gegevens naar de juiste workspace. Beschikbaar op de Pro- en Scale-abonnementen.",
|
||||
"upgrade_prompt_title": "Upgrade om Feedbackrecord Mappen te ontgrendelen",
|
||||
"workspace_access": "Workspace-toegang",
|
||||
"workspace_assigned_to_directory": "{workspaceName} is gekoppeld aan {directoryName}",
|
||||
"workspaces_already_linked": "Reeds gekoppelde werkruimtes",
|
||||
"workspaces_being_added": "Werkruimtes die toegang krijgen"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Toegang verloren",
|
||||
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
|
||||
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
|
||||
@@ -2715,8 +2711,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 mislukt. Probeer je account opnieuw te verwijderen.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het zijn dat je bij het selecteren van Verwijderen wordt doorgestuurd naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat het verwijderen automatisch door.",
|
||||
"sso_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
|
||||
"two_factor_authentication": "Tweefactorauthenticatie",
|
||||
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in vraag {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de eindkaart",
|
||||
"hidden_field_used_in_recall_welcome": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de welkomstkaart.",
|
||||
"hidden_fields_description": "Geef verborgen gegevens door aan je enquête zonder dat respondenten het zien.",
|
||||
"hide_back_button": "Knop 'Terug' verbergen",
|
||||
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
|
||||
"hide_block_settings": "Blokinstellingen verbergen",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variabele {variable} wordt opgeroepen in de eindkaart",
|
||||
"variable_used_in_recall_welcome": "Variabele \"{variable}\" wordt opgeroepen in de welkomstkaart.",
|
||||
"variables_description": "Definieer en bereken waarden tijdens je enquête.",
|
||||
"verify_email_before_submission": "Verifieer uw e-mailadres voordat u het verzendt",
|
||||
"verify_email_before_submission_description": "Laat alleen mensen met een echte e-mail reageren.",
|
||||
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
|
||||
"no_responses_found": "Geen reacties gevonden",
|
||||
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
|
||||
"open_response_details": "Details open antwoorden",
|
||||
"other_values_found": "Andere waarden gevonden",
|
||||
"overall": "Algemeen",
|
||||
"promoters": "Promoters",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Toegestane waarden: {values}",
|
||||
"api_ingestion": "API-inname",
|
||||
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
|
||||
"api_ingestion_setup_description": "Gebruik de REST API om feedbackgegevens rechtstreeks naar Formbricks te sturen. De API-ingestiedocumentatie bevat het endpoint, de payload-structuur en authenticatiegegevens.",
|
||||
"auto_generated": "Automatisch gegenereerd",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"clear_mapping": "Mapping wissen",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
|
||||
"ai_enable_in_settings": "Ative nas configurações da organização.",
|
||||
"ai_instance_not_configured": "A IA não está configurada nesta instância. Entre em contato com seu administrador.",
|
||||
"ai_not_available": "A análise de dados com IA não está disponível.",
|
||||
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização.",
|
||||
"ai_not_in_plan": "A análise de dados por IA não está disponível no seu plano atual.",
|
||||
"ai_not_enabled": "A análise de dados com IA está desabilitada para esta organização. Habilite nas configurações da organização.",
|
||||
"ai_not_in_plan": "A análise de dados com IA não está disponível no seu plano atual. Faça upgrade para desbloquear este recurso.",
|
||||
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
|
||||
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunte aos seus dados",
|
||||
"ai_upgrade_plan": "Fazer upgrade do plano",
|
||||
"already_on_dashboard": "Já está no painel",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Você não tem permissão para gerenciar diretórios de feedback.",
|
||||
"no_connectors": "Nenhuma fonte de feedback vinculada a este diretório ainda.",
|
||||
"no_unassigned_workspaces_description": "Todos os workspaces já estão vinculados a um diretório de feedback ativo. Remova um workspace do seu diretório atual antes de atribuí-lo aqui.",
|
||||
"no_unassigned_workspaces_title": "Nenhum workspace não atribuído disponível",
|
||||
"pause_connectors_confirmation_description": "Pausar essas fontes de feedback impedirá que novos registros sejam adicionados.",
|
||||
"pause_connectors_confirmation_title": "Pausar fontes de feedback vinculadas?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organize registros de feedback em diretórios e direcione dados para o workspace certo. Disponível nos planos Pro e Scale.",
|
||||
"upgrade_prompt_title": "Faça upgrade para desbloquear Diretórios de Registros de Feedback",
|
||||
"workspace_access": "Acesso ao workspace",
|
||||
"workspace_assigned_to_directory": "{workspaceName} está vinculado a {directoryName}",
|
||||
"workspaces_already_linked": "Workspaces já vinculados",
|
||||
"workspaces_being_added": "Workspaces recebendo acesso"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
@@ -2715,8 +2711,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": "Falha na confirmação de identidade SSO. Tente excluir sua conta novamente.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionar Excluir você pode ser redirecionado para seu provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continua automaticamente.",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
|
||||
"hidden_fields_description": "Passe dados ocultos para sua pesquisa sem mostrá-los aos respondentes.",
|
||||
"hide_back_button": "Ocultar botão 'Voltar'",
|
||||
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
|
||||
"hide_block_settings": "Ocultar configurações do bloco",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
|
||||
"variables_description": "Defina e calcule valores ao longo da sua pesquisa.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
|
||||
"visibility_and_recontact": "Visibilidade e recontato",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
|
||||
"open_response_details": "Detalhes das respostas abertas",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"promoters": "Promotores",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
|
||||
"api_ingestion_setup_description": "Use a API REST para enviar registros de feedback diretamente para o Formbricks. A documentação de ingestão da API inclui o endpoint, a estrutura do payload e detalhes de autenticação.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar arquivo",
|
||||
"clear_mapping": "Limpar mapeamento",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Adicionar filtro",
|
||||
"add_to_dashboard": "Adicionar ao painel",
|
||||
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
|
||||
"ai_enable_in_settings": "Ative nas definições da organização.",
|
||||
"ai_instance_not_configured": "A IA não está configurada nesta instância. Contacta o teu administrador.",
|
||||
"ai_not_available": "A análise de dados por IA não está disponível.",
|
||||
"ai_not_enabled": "A análise de dados com IA está desativada para esta organização.",
|
||||
"ai_not_in_plan": "A análise de dados com IA não está disponível no teu plano atual.",
|
||||
"ai_not_enabled": "A análise de dados por IA está desativada para esta organização. Ativa-a nas definições da organização.",
|
||||
"ai_not_in_plan": "A análise de dados por IA não está disponível no teu plano atual. Faz upgrade para desbloquear esta funcionalidade.",
|
||||
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
|
||||
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunta aos teus dados",
|
||||
"ai_upgrade_plan": "Atualizar plano",
|
||||
"already_on_dashboard": "Já está no painel",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Não tens permissão para gerir diretórios de feedback.",
|
||||
"no_connectors": "Ainda sem fontes de feedback associadas a este diretório.",
|
||||
"no_unassigned_workspaces_description": "Todos os espaços de trabalho já estão associados a um diretório de feedback ativo. Remove um espaço de trabalho do seu diretório atual antes de o atribuíres aqui.",
|
||||
"no_unassigned_workspaces_title": "Nenhum espaço de trabalho disponível sem atribuição",
|
||||
"pause_connectors_confirmation_description": "Pausar estas fontes de feedback irá impedir a adição de novos registos.",
|
||||
"pause_connectors_confirmation_title": "Pausar fontes de feedback associadas?",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organiza os registos de feedback em diretórios e encaminha os dados para o workspace certo. Disponível nos planos Pro e Scale.",
|
||||
"upgrade_prompt_title": "Faz upgrade para desbloquear Diretórios de Registos de Feedback",
|
||||
"workspace_access": "Acesso ao workspace",
|
||||
"workspace_assigned_to_directory": "{workspaceName} está associado a {directoryName}",
|
||||
"workspaces_already_linked": "Workspaces já vinculados",
|
||||
"workspaces_being_added": "Workspaces a receber acesso"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
@@ -2715,8 +2711,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 SSO falhou. Por favor, tenta eliminar a tua conta novamente.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, ao selecionares Eliminar podes ser redirecionado para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continua automaticamente.",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar poderá redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
|
||||
"hidden_fields_description": "Passa dados ocultos para o teu inquérito sem os mostrar aos inquiridos.",
|
||||
"hide_back_button": "Ocultar botão 'Retroceder'",
|
||||
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
|
||||
"hide_block_settings": "Ocultar definições do bloco",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
|
||||
"variables_description": "Define e calcula valores ao longo do teu inquérito.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
|
||||
"visibility_and_recontact": "Visibilidade e Recontacto",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Sem impressões de contactos identificados",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
|
||||
"open_response_details": "Detalhes de respostas abertas",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"promoters": "Promotores",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Valores permitidos: {values}",
|
||||
"api_ingestion": "Ingestão de API",
|
||||
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
|
||||
"api_ingestion_setup_description": "Usa a REST API para enviar registos de feedback diretamente para o Formbricks. A documentação da API de ingestão inclui o endpoint, a estrutura do payload e os detalhes de autenticação.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"clear_mapping": "Limpar mapeamento",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Adaugă filtru",
|
||||
"add_to_dashboard": "Adaugă la Tablou de Bord",
|
||||
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
|
||||
"ai_enable_in_settings": "Activează-l în setările organizației.",
|
||||
"ai_instance_not_configured": "AI nu este configurat pe această instanță. Contactează administratorul.",
|
||||
"ai_not_available": "Analiza datelor cu AI nu este disponibilă.",
|
||||
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație.",
|
||||
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău curent.",
|
||||
"ai_not_enabled": "Analiza datelor cu AI este dezactivată pentru această organizație. Activează-o în setările organizației.",
|
||||
"ai_not_in_plan": "Analiza datelor cu AI nu este disponibilă în planul tău actual. Treci la un plan superior pentru a debloca această funcție.",
|
||||
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
|
||||
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
|
||||
"ai_query_section_title": "Întreabă-ți datele",
|
||||
"ai_upgrade_plan": "Actualizează planul",
|
||||
"already_on_dashboard": "Deja pe tabloul de bord",
|
||||
"and_filter_logic": "ȘI",
|
||||
"apply_changes": "Aplică modificările",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Directoare de feedback",
|
||||
"no_access": "Nu ai permisiunea de a gestiona directoarele de feedback.",
|
||||
"no_connectors": "Nicio sursă de feedback conectată la acest director încă.",
|
||||
"no_unassigned_workspaces_description": "Fiecare spațiu de lucru este deja conectat la un director de feedback activ. Elimină un spațiu de lucru din directorul său actual înainte de a-l atribui aici.",
|
||||
"no_unassigned_workspaces_title": "Niciun spațiu de lucru neatribuit disponibil",
|
||||
"pause_connectors_confirmation_description": "Pauza acestor surse de feedback va opri adăugarea de noi înregistrări.",
|
||||
"pause_connectors_confirmation_title": "Pui pe pauză sursele de feedback conectate?",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organizează înregistrările de feedback în directoare și direcționează datele către workspace-ul potrivit. Disponibile în planurile Pro și Scale.",
|
||||
"upgrade_prompt_title": "Actualizează pentru a debloca Directoarele pentru Înregistrări de Feedback",
|
||||
"workspace_access": "Acces la spațiul de lucru",
|
||||
"workspace_assigned_to_directory": "{workspaceName} este conectat la {directoryName}",
|
||||
"workspaces_already_linked": "Spații de lucru deja conectate",
|
||||
"workspaces_being_added": "Spații de lucru cărora li se acordă acces"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acces pierdut",
|
||||
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
|
||||
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
|
||||
@@ -2715,8 +2711,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 să ștergi contul din nou.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
|
||||
"sso_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters automat.",
|
||||
"two_factor_authentication": "Autentificare în doi pași",
|
||||
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
|
||||
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
|
||||
"hidden_fields_description": "Transmite date ascunse în sondajul tău fără a le afișa respondenților.",
|
||||
"hide_back_button": "Ascunde butonul 'Înapoi'",
|
||||
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
|
||||
"hide_block_settings": "Ascunde setările blocului",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
|
||||
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
|
||||
"variables_description": "Definește și calculează valori pe parcursul sondajului tău.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
|
||||
"visibility_and_recontact": "Vizibilitate și recontactare",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Nicio impresie de la contactele identificate",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
|
||||
"open_response_details": "Detalii răspunsuri deschise",
|
||||
"other_values_found": "Alte valori găsite",
|
||||
"overall": "General",
|
||||
"promoters": "Promotori",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Valori permise: {values}",
|
||||
"api_ingestion": "Ingestie API",
|
||||
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
|
||||
"api_ingestion_setup_description": "Folosește REST API pentru a trimite înregistrări de feedback direct în Formbricks. Documentația de ingestie API include endpoint-ul, structura payload-ului și detaliile de autentificare.",
|
||||
"auto_generated": "Generat automat",
|
||||
"change_file": "Schimbă fișierul",
|
||||
"clear_mapping": "Șterge maparea",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Добавить фильтр",
|
||||
"add_to_dashboard": "Добавить на панель",
|
||||
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
|
||||
"ai_enable_in_settings": "Включите эту функцию в настройках организации.",
|
||||
"ai_instance_not_configured": "ИИ не настроен на этом экземпляре. Свяжитесь с администратором.",
|
||||
"ai_not_available": "Анализ данных с помощью ИИ недоступен.",
|
||||
"ai_not_enabled": "ИИ-анализ данных отключён для этой организации.",
|
||||
"ai_not_in_plan": "ИИ-анализ данных недоступен в твоём текущем тарифе.",
|
||||
"ai_not_enabled": "Анализ данных с помощью ИИ отключён для этой организации. Включите его в настройках организации.",
|
||||
"ai_not_in_plan": "Анализ данных с помощью ИИ недоступен в вашем текущем тарифе. Обновите тариф, чтобы получить эту функцию.",
|
||||
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
|
||||
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
|
||||
"ai_query_section_title": "Спроси свои данные",
|
||||
"ai_upgrade_plan": "Обновить тариф",
|
||||
"already_on_dashboard": "Уже на дашборде",
|
||||
"and_filter_logic": "И",
|
||||
"apply_changes": "Применить изменения",
|
||||
@@ -2429,7 +2427,7 @@
|
||||
"most_popular": "Самый популярный",
|
||||
"pending_change_removed": "Запланированное изменение тарифа отменено.",
|
||||
"pending_plan_badge": "Запланирован",
|
||||
"pending_plan_change_description": "Твой тариф сменится на {plan} на {date}.",
|
||||
"pending_plan_change_description": "Твой тариф сменится на {plan} {date}.",
|
||||
"pending_plan_change_title": "Запланированное изменение тарифа",
|
||||
"pending_plan_cta": "Запланирован",
|
||||
"per_month": "в месяц",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления директориями обратной связи.",
|
||||
"no_connectors": "К этому каталогу пока не привязаны источники отзывов.",
|
||||
"no_unassigned_workspaces_description": "Каждое рабочее пространство уже связано с активным каталогом отзывов. Удалите рабочее пространство из текущего каталога, прежде чем назначить его сюда.",
|
||||
"no_unassigned_workspaces_title": "Нет доступных неназначенных рабочих пространств",
|
||||
"pause_connectors_confirmation_description": "Приостановка этих источников отзывов остановит добавление новых записей.",
|
||||
"pause_connectors_confirmation_title": "Приостановить связанные источники отзывов?",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Организуй записи обратной связи в директории и направляй данные в нужное рабочее пространство. Доступно в тарифах Pro и Scale.",
|
||||
"upgrade_prompt_title": "Обнови тариф, чтобы получить доступ к директориям записей обратной связи",
|
||||
"workspace_access": "Доступ к рабочему пространству",
|
||||
"workspace_assigned_to_directory": "{workspaceName} связано с {directoryName}",
|
||||
"workspaces_already_linked": "Уже связанные рабочие пространства",
|
||||
"workspaces_being_added": "Рабочие пространства, которым предоставляется доступ"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Потерян доступ",
|
||||
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
|
||||
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
|
||||
@@ -2715,8 +2711,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-аккаунтов при выборе «Удалить» может потребоваться переход к вашему провайдеру идентификации для подтверждения этого аккаунта. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
|
||||
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Для учетных записей SSO выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
|
||||
"two_factor_authentication": "Двухфакторная аутентификация",
|
||||
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Скрытое поле «{hiddenField}» используется в вопросе {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Скрытое поле «{hiddenField}» используется в финальной карточке",
|
||||
"hidden_field_used_in_recall_welcome": "Скрытое поле «{hiddenField}» используется в приветственной карточке.",
|
||||
"hidden_fields_description": "Передавайте скрытые данные в опрос, не показывая их респондентам.",
|
||||
"hide_back_button": "Скрыть кнопку «Назад»",
|
||||
"hide_back_button_description": "Не отображать кнопку «Назад» в опросе",
|
||||
"hide_block_settings": "Скрыть настройки блока",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Переменная {variable} используется в финальной карточке",
|
||||
"variable_used_in_recall_welcome": "Переменная «{variable}» используется в приветственной карточке.",
|
||||
"variables_description": "Определяйте и вычисляйте значения на протяжении всего опроса.",
|
||||
"verify_email_before_submission": "Проверять email перед отправкой",
|
||||
"verify_email_before_submission_description": "Разрешить отвечать только пользователям с реальным email.",
|
||||
"visibility_and_recontact": "Видимость и повторный контакт",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Нет показов от идентифицированных контактов",
|
||||
"no_responses_found": "Ответы не найдены",
|
||||
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
|
||||
"open_response_details": "Детали открытых ответов",
|
||||
"other_values_found": "Найдены другие значения",
|
||||
"overall": "В целом",
|
||||
"promoters": "Сторонники",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Допустимые значения: {values}",
|
||||
"api_ingestion": "Импорт через API",
|
||||
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
|
||||
"api_ingestion_setup_description": "Используйте REST API для прямой отправки записей отзывов в Formbricks. Документация по API включает конечную точку, структуру данных и сведения об аутентификации.",
|
||||
"auto_generated": "Автоматически генерируется",
|
||||
"change_file": "Изменить файл",
|
||||
"clear_mapping": "Очистить сопоставление",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Lägg till filter",
|
||||
"add_to_dashboard": "Lägg till på instrumentpanelen",
|
||||
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
|
||||
"ai_enable_in_settings": "Aktivera det i organisationsinställningarna.",
|
||||
"ai_instance_not_configured": "AI är inte konfigurerad på denna instans. Kontakta din administratör.",
|
||||
"ai_not_available": "AI-dataanalys är inte tillgänglig.",
|
||||
"ai_not_enabled": "AI-dataanalys är inaktiverad för den här organisationen.",
|
||||
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan.",
|
||||
"ai_not_enabled": "AI-dataanalys är inaktiverad för denna organisation. Aktivera det i organisationsinställningarna.",
|
||||
"ai_not_in_plan": "AI-dataanalys är inte tillgänglig i din nuvarande plan. Uppgradera för att låsa upp denna funktion.",
|
||||
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
|
||||
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
|
||||
"ai_query_section_title": "Fråga din data",
|
||||
"ai_upgrade_plan": "Uppgradera plan",
|
||||
"already_on_dashboard": "Redan på instrumentpanelen",
|
||||
"and_filter_logic": "OCH",
|
||||
"apply_changes": "Verkställ ändringar",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Feedbackkataloger",
|
||||
"no_access": "Du har inte behörighet att hantera feedback-kataloger.",
|
||||
"no_connectors": "Inga feedbackkällor länkade till den här katalogen ännu.",
|
||||
"no_unassigned_workspaces_description": "Varje arbetsyta är redan kopplad till en aktiv feedbackkatalog. Ta bort en arbetsyta från dess nuvarande katalog innan du tilldelar den här.",
|
||||
"no_unassigned_workspaces_title": "Inga otilldelade arbetsytor tillgängliga",
|
||||
"pause_connectors_confirmation_description": "Att pausa dessa feedbackkällor kommer att stoppa nya poster från att läggas till.",
|
||||
"pause_connectors_confirmation_title": "Pausa länkade feedbackkällor?",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Organisera feedbackposter i kataloger och dirigera data till rätt arbetsyta. Tillgängligt på Pro- och Scale-planerna.",
|
||||
"upgrade_prompt_title": "Uppgradera för att låsa upp Feedbackpostkataloger",
|
||||
"workspace_access": "Arbetsyteåtkomst",
|
||||
"workspace_assigned_to_directory": "{workspaceName} är kopplad till {directoryName}",
|
||||
"workspaces_already_linked": "Redan länkade arbetsytor",
|
||||
"workspaces_being_added": "Arbetsytor som beviljas åtkomst"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Förlorad åtkomst",
|
||||
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
|
||||
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
|
||||
@@ -2715,8 +2711,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äftelse misslyckades. Försök att radera ditt konto igen.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan ett klick på Radera omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter raderingen automatiskt.",
|
||||
"sso_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort automatiskt.",
|
||||
"two_factor_authentication": "Tvåfaktorsautentisering",
|
||||
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Dolt fält \"{hiddenField}\" återkallas i fråga {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Dolt fält \"{hiddenField}\" återkallas i avslutningskortet",
|
||||
"hidden_field_used_in_recall_welcome": "Dolt fält \"{hiddenField}\" återkallas i välkomstkortet.",
|
||||
"hidden_fields_description": "Skicka dold data till din enkät utan att visa den för respondenter.",
|
||||
"hide_back_button": "Dölj 'Tillbaka'-knapp",
|
||||
"hide_back_button_description": "Visa inte tillbakaknappen i enkäten",
|
||||
"hide_block_settings": "Dölj blockinställningar",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variabel {variable} återkallas i avslutningskortet",
|
||||
"variable_used_in_recall_welcome": "Variabel \"{variable}\" återkallas i välkomstkortet.",
|
||||
"variables_description": "Definiera och beräkna värden genom hela din enkät.",
|
||||
"verify_email_before_submission": "Verifiera e-post före inskickning",
|
||||
"verify_email_before_submission_description": "Låt endast personer med en riktig e-post svara.",
|
||||
"visibility_and_recontact": "Synlighet och återkontakt",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Inga visningar från identifierade kontakter",
|
||||
"no_responses_found": "Inga svar hittades",
|
||||
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
|
||||
"open_response_details": "Detaljer för öppna svar",
|
||||
"other_values_found": "Andra värden hittades",
|
||||
"overall": "Övergripande",
|
||||
"promoters": "Ambassadörer",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "Tillåtna värden: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"api_ingestion_setup_description": "Använd REST API för att skicka feedbackposter direkt till Formbricks. API-dokumentationen innehåller endpoint, datastruktur och autentiseringsdetaljer.",
|
||||
"auto_generated": "Automatiskt genererad",
|
||||
"change_file": "Byt fil",
|
||||
"clear_mapping": "Rensa mappning",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "Filtre ekle",
|
||||
"add_to_dashboard": "Panoya Ekle",
|
||||
"advanced_chart_builder_config_prompt": "Grafiğini yapılandır ve önizleme için \"Sorguyu Çalıştır\"a tıkla",
|
||||
"ai_enable_in_settings": "Organizasyon ayarlarından etkinleştirin.",
|
||||
"ai_instance_not_configured": "Bu örnekte AI yapılandırılmamış. Yöneticinle iletişime geç.",
|
||||
"ai_not_available": "AI veri analizi mevcut değil.",
|
||||
"ai_not_enabled": "Bu kuruluş için AI veri analizi devre dışı bırakılmış.",
|
||||
"ai_not_in_plan": "AI veri analizi mevcut planınızda mevcut değil.",
|
||||
"ai_not_enabled": "Bu organizasyon için AI veri analizi devre dışı. Organizasyon ayarlarından etkinleştir.",
|
||||
"ai_not_in_plan": "AI veri analizi mevcut planında bulunmuyor. Bu özelliğin kilidini açmak için yükselt.",
|
||||
"ai_query_placeholder": "örn. Geçen hafta kaç kullanıcı kaydoldu?",
|
||||
"ai_query_section_description": "Ne görmek istediğini anlat, AI grafiği oluştursun.",
|
||||
"ai_query_section_title": "Verilerine sor",
|
||||
"ai_upgrade_plan": "Planı yükselt",
|
||||
"already_on_dashboard": "Zaten panoda",
|
||||
"and_filter_logic": "VE",
|
||||
"apply_changes": "Değişiklikleri Uygula",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "Geri Bildirim Dizinleri",
|
||||
"no_access": "Geri bildirim dizinlerini yönetme yetkin yok.",
|
||||
"no_connectors": "Bu dizine henüz bağlı geri bildirim kaynağı yok.",
|
||||
"no_unassigned_workspaces_description": "Her çalışma alanı zaten aktif bir geri bildirim dizinine bağlı. Buraya atamadan önce bir çalışma alanını mevcut dizininden kaldırın.",
|
||||
"no_unassigned_workspaces_title": "Atanmamış çalışma alanı yok",
|
||||
"pause_connectors_confirmation_description": "Bu geri bildirim kaynaklarını duraklatmak, yeni kayıtların eklenmesini durdurur.",
|
||||
"pause_connectors_confirmation_title": "Bağlı geri bildirim kaynakları duraklatılsın mı?",
|
||||
"select_workspaces_placeholder": "Çalışma alanlarını seç...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "Geri bildirim kayıtlarını dizinler halinde düzenleyin ve verileri doğru çalışma alanına yönlendirin. Pro ve Scale planlarında kullanılabilir.",
|
||||
"upgrade_prompt_title": "Geri Bildirim Kayıt Dizinlerinin Kilidini Açmak İçin Yükseltin",
|
||||
"workspace_access": "Çalışma alanı erişimi",
|
||||
"workspace_assigned_to_directory": "{workspaceName}, {directoryName} dizinine bağlı",
|
||||
"workspaces_already_linked": "Zaten bağlı çalışma alanları",
|
||||
"workspaces_being_added": "Erişim verilen çalışma alanları"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "E-posta onayı eşleşmiyor.",
|
||||
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamandaki kodu aşağıya gir.",
|
||||
"google_sso_account_deletion_requires_setup": "SSO sağlayıcınızla kimliğinizi doğrulayamadık. Lütfen tekrar deneyin veya yöneticinizle iletişime geçin.",
|
||||
"lost_access": "Erişimi kaybettim",
|
||||
"or_enter_the_following_code_manually": "Ya da aşağıdaki kodu manuel olarak gir:",
|
||||
"organizations_delete_message": "Bu organizasyonların tek sahibi sensin, bu yüzden <b>onlar da silinecek.</b>",
|
||||
@@ -2715,8 +2711,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedekleme kodlarını güvenli bir yerde sakla.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanla tara.",
|
||||
"security_description": "Şifreni ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönet.",
|
||||
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı tekrar silmeyi deneyin.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesapları için Sil seçeneğine tıkladığınızda, bu hesabı onaylamak için kimlik sağlayıcınıza yönlendirilebilirsiniz. Aynı hesap onaylanırsa, silme işlemi otomatik olarak devam eder.",
|
||||
"sso_reauthentication_failed": "SSO yeniden kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSO hesapları için Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa, hesabınız otomatik olarak silinecektir.",
|
||||
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
|
||||
"two_factor_authentication_description": "Şifren çalınması durumunda hesabına ekstra bir güvenlik katmanı ekle.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulayıcı uygulamandaki altı haneli kodu gir.",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "Gizli alan \"{hiddenField}\" soru {questionIndex}'te hatırlatılıyor.",
|
||||
"hidden_field_used_in_recall_ending_card": "Gizli alan \"{hiddenField}\" Bitiş Kartında hatırlatılıyor",
|
||||
"hidden_field_used_in_recall_welcome": "Gizli alan \"{hiddenField}\" Hoş Geldiniz kartında hatırlatılıyor.",
|
||||
"hidden_fields_description": "Gizli verileri yanıtlayanlara göstermeden anketine aktar.",
|
||||
"hide_back_button": "\"Geri\" düğmesini gizle",
|
||||
"hide_back_button_description": "Ankette geri düğmesini gösterme",
|
||||
"hide_block_settings": "Blok ayarlarını gizle",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "“{variable}” değişkeni, {questionIndex}. soruda geri çağrılıyor.",
|
||||
"variable_used_in_recall_ending_card": "{variable} değişkeni Bitiş Kartı'nda geri çağrılıyor.",
|
||||
"variable_used_in_recall_welcome": "“{variable}” değişkeni Hoş Geldin Kartı'nda geri çağrılıyor.",
|
||||
"variables_description": "Anketin boyunca değerleri tanımla ve hesapla.",
|
||||
"verify_email_before_submission": "Göndermeden önce e-posta doğrula",
|
||||
"verify_email_before_submission_description": "Sadece gerçek bir e-postaya sahip kişilerin yanıt vermesine izin ver.",
|
||||
"visibility_and_recontact": "Görünürlük & Yeniden İletişim",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "Tanımlanmış kişilerden gösterim yok",
|
||||
"no_responses_found": "Yanıt bulunamadı",
|
||||
"nps_promoters_tooltip": "Yanıt verenlerin %{percentage}'si 9 veya 10 puan verdi (NPS tavsiye edenler).",
|
||||
"open_response_details": "Açık yanıt detayları",
|
||||
"other_values_found": "Diğer değerler bulundu",
|
||||
"overall": "Genel",
|
||||
"promoters": "Tavsiye edenler",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "İzin verilen değerler: {values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"api_ingestion_setup_description": "Geri bildirim kayıtlarını doğrudan Formbricks'e göndermek için REST API'sini kullanın. API entegrasyon dokümanları, endpoint, payload yapısı ve kimlik doğrulama detaylarını içerir.",
|
||||
"auto_generated": "Otomatik olarak oluşturuldu",
|
||||
"change_file": "Dosyayı değiştir",
|
||||
"clear_mapping": "Eşleştirmeyi temizle",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "添加过滤器",
|
||||
"add_to_dashboard": "添加到 Dashboard",
|
||||
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
|
||||
"ai_enable_in_settings": "在组织设置中启用。",
|
||||
"ai_instance_not_configured": "此实例未配置 AI。请联系您的管理员。",
|
||||
"ai_not_available": "AI 数据分析不可用。",
|
||||
"ai_not_enabled": "此组织已禁用 AI 数据分析。",
|
||||
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析功能。",
|
||||
"ai_not_enabled": "此组织已禁用 AI 数据分析。请在组织设置中启用。",
|
||||
"ai_not_in_plan": "您当前的套餐不包含 AI 数据分析。升级以解锁此功能。",
|
||||
"ai_query_placeholder": "例如:上周有多少用户注册?",
|
||||
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
|
||||
"ai_query_section_title": "向你的数据提问",
|
||||
"ai_upgrade_plan": "升级套餐",
|
||||
"already_on_dashboard": "已在仪表板上",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "应用更改",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈目录的权限。",
|
||||
"no_connectors": "暂未关联反馈来源到此目录。",
|
||||
"no_unassigned_workspaces_description": "每个工作区都已链接到活跃的反馈目录。在此处分配前,请先从当前目录中移除工作区。",
|
||||
"no_unassigned_workspaces_title": "没有可用的未分配工作区",
|
||||
"pause_connectors_confirmation_description": "暂停这些反馈来源后,将不会有新记录添加进来。",
|
||||
"pause_connectors_confirmation_title": "暂停关联反馈来源?",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "将反馈记录整理到目录中,并将数据路由到正确的工作空间。专业版和规模版方案可用。",
|
||||
"upgrade_prompt_title": "升级以解锁反馈记录目录",
|
||||
"workspace_access": "工作区访问权限",
|
||||
"workspace_assigned_to_directory": "{workspaceName} 已链接到 {directoryName}",
|
||||
"workspaces_already_linked": "已关联的工作区",
|
||||
"workspaces_being_added": "将被授权访问的工作区"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "启用 双因素 认证",
|
||||
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "失去访问",
|
||||
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
|
||||
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
|
||||
@@ -2715,8 +2711,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 账户,选择删除可能会将您重定向到身份提供商以确认此账户。如果确认的是同一账户,删除将自动继续。",
|
||||
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除您的账号。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将您重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
|
||||
"two_factor_authentication": "双因素 认证",
|
||||
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
|
||||
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
|
||||
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
|
||||
"hidden_fields_description": "将隐藏数据传递到您的调查中,而不向受访者显示。",
|
||||
"hide_back_button": "隐藏 \"返回\" 按钮",
|
||||
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
|
||||
"hide_block_settings": "隐藏区块设置",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
|
||||
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
|
||||
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
|
||||
"variables_description": "在整个调查过程中定义和计算值。",
|
||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
|
||||
"visibility_and_recontact": "可见性与重新联系",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "没有已识别联系人的展示次数",
|
||||
"no_responses_found": "未找到响应",
|
||||
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
|
||||
"open_response_details": "开放式回答详情",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整体",
|
||||
"promoters": "推荐者",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "允许的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"api_ingestion_setup_description": "使用 REST API 直接将反馈记录发送到 Formbricks。API 接入文档包含端点、请求体结构和身份验证详情。",
|
||||
"auto_generated": "自动生成",
|
||||
"change_file": "更换文件",
|
||||
"clear_mapping": "清除映射",
|
||||
|
||||
@@ -1667,15 +1667,13 @@
|
||||
"add_filter": "新增篩選器",
|
||||
"add_to_dashboard": "新增到儀表板",
|
||||
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
|
||||
"ai_enable_in_settings": "請在組織設定中啟用。",
|
||||
"ai_instance_not_configured": "此執行個體未設定 AI。請聯絡您的管理員。",
|
||||
"ai_not_available": "AI 資料分析無法使用。",
|
||||
"ai_not_enabled": "此組織已停用 AI 資料分析功能。",
|
||||
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析功能。",
|
||||
"ai_not_enabled": "此組織已停用 AI 資料分析。請在組織設定中啟用。",
|
||||
"ai_not_in_plan": "您目前的方案不包含 AI 資料分析。請升級以解鎖此功能。",
|
||||
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
|
||||
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
|
||||
"ai_query_section_title": "詢問你的數據",
|
||||
"ai_upgrade_plan": "升級方案",
|
||||
"already_on_dashboard": "已在儀表板上",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "套用變更",
|
||||
@@ -2593,8 +2591,6 @@
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "你沒有權限管理意見回饋目錄。",
|
||||
"no_connectors": "此目錄尚未連結任何回饋來源。",
|
||||
"no_unassigned_workspaces_description": "每個工作區都已連結到作用中的意見回饋目錄。請先從目前目錄中移除工作區,再於此處指派。",
|
||||
"no_unassigned_workspaces_title": "沒有可用的未指派工作區",
|
||||
"pause_connectors_confirmation_description": "暫停這些回饋來源將停止新增記錄。",
|
||||
"pause_connectors_confirmation_title": "暫停已連結的回饋來源?",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
@@ -2605,7 +2601,6 @@
|
||||
"upgrade_prompt_description": "將回饋記錄整理至目錄中,並將資料導向正確的工作區。專業版和企業版方案提供此功能。",
|
||||
"upgrade_prompt_title": "升級以解鎖回饋記錄目錄功能",
|
||||
"workspace_access": "工作區存取權限",
|
||||
"workspace_assigned_to_directory": "{workspaceName} 已連結到 {directoryName}",
|
||||
"workspaces_already_linked": "已連結的工作區",
|
||||
"workspaces_being_added": "正在授予存取權限的工作區"
|
||||
},
|
||||
@@ -2705,6 +2700,7 @@
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "無法存取",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
@@ -2715,8 +2711,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 帳號,選擇刪除時可能會將您重新導向至身分提供者以確認此帳號。若確認的是同一帳號,刪除作業將自動繼續。",
|
||||
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除您的帳號。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "對於 SSO 帳戶,選取刪除可能會將您重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
|
||||
"two_factor_authentication": "雙重驗證",
|
||||
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
|
||||
@@ -3025,7 +3021,6 @@
|
||||
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
|
||||
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
|
||||
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
|
||||
"hidden_fields_description": "將隱藏資料傳入你的問卷中,而不會顯示給受訪者。",
|
||||
"hide_back_button": "隱藏「Back」按鈕",
|
||||
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
|
||||
"hide_block_settings": "隱藏區塊設定",
|
||||
@@ -3331,7 +3326,6 @@
|
||||
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
|
||||
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
|
||||
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
|
||||
"variables_description": "在整個問卷中定義和計算數值。",
|
||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
|
||||
"visibility_and_recontact": "可見性與重新聯絡",
|
||||
@@ -3603,6 +3597,7 @@
|
||||
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
|
||||
"no_responses_found": "找不到回應",
|
||||
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
|
||||
"open_response_details": "開放式回覆詳情",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"promoters": "推廣者",
|
||||
@@ -3680,7 +3675,6 @@
|
||||
"allowed_values": "允許的值:{values}",
|
||||
"api_ingestion": "API ingestion",
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"api_ingestion_setup_description": "使用 REST API 直接將意見回饋記錄傳送至 Formbricks。API 擷取文件包含端點、負載格式及驗證詳情。",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "更換檔案",
|
||||
"clear_mapping": "清除對應",
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
|
||||
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 { 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";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
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 ZStartAccountDeletionSsoReauth = z
|
||||
.object({
|
||||
confirmationEmail: z.string().trim().pipe(ZUserEmail),
|
||||
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -26,16 +29,31 @@ 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)
|
||||
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
|
||||
.inputSchema(ZStartAccountDeletionSsoReauth)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const userId = ctx.user.id;
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
|
||||
const { confirmationEmail, returnToUrl } = parsedInput;
|
||||
|
||||
return await startAccountDeletionSsoReauthentication({
|
||||
confirmationEmail,
|
||||
returnToUrl,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUserConfirmation).action(
|
||||
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
|
||||
const { confirmationEmail, password } = parsedInput;
|
||||
|
||||
@@ -43,45 +61,16 @@ export const deleteUserAction = authenticatedActionClient
|
||||
confirmationEmail,
|
||||
password,
|
||||
userEmail: ctx.user.email,
|
||||
userId,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
|
||||
ctx.auditLoggingCtx.oldObject = oldUser;
|
||||
|
||||
capturePostHogEvent(userId, "delete_account");
|
||||
capturePostHogEvent(ctx.user.id, "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);
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
@@ -19,7 +20,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { deleteUserAction } from "./actions";
|
||||
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
|
||||
|
||||
interface DeleteAccountModalProps {
|
||||
requiresPasswordConfirmation: boolean;
|
||||
@@ -28,7 +29,6 @@ interface DeleteAccountModalProps {
|
||||
user: TUser;
|
||||
isFormbricksCloud: boolean;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isSsoIdentityConfirmationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccountModal = ({
|
||||
@@ -38,7 +38,6 @@ export const DeleteAccountModal = ({
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
organizationsWithSingleOwner,
|
||||
isSsoIdentityConfirmationDisabled,
|
||||
}: Readonly<DeleteAccountModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -66,6 +65,10 @@ export const DeleteAccountModal = ({
|
||||
return t("workspace.settings.profile.wrong_password");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
return t("workspace.settings.profile.google_sso_account_deletion_requires_setup");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
|
||||
return t("workspace.settings.profile.email_confirmation_does_not_match");
|
||||
}
|
||||
@@ -75,12 +78,39 @@ export const DeleteAccountModal = ({
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
|
||||
return t("workspace.settings.profile.sso_identity_confirmation_failed");
|
||||
return t("workspace.settings.profile.sso_reauthentication_failed");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const startSsoReauthentication = async () => {
|
||||
const result = await startAccountDeletionSsoReauthenticationAction({
|
||||
confirmationEmail: inputValue,
|
||||
returnToUrl: globalThis.location.href,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
|
||||
const errorMessage =
|
||||
getLocalizedDeletionErrorMessage(result?.serverError) ??
|
||||
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn(
|
||||
result.data.provider,
|
||||
{
|
||||
callbackUrl: result.data.callbackUrl,
|
||||
redirect: true,
|
||||
},
|
||||
result.data.authorizationParams
|
||||
);
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
if (!hasValidConfirmation) {
|
||||
@@ -93,47 +123,37 @@ export const DeleteAccountModal = ({
|
||||
? {
|
||||
confirmationEmail: inputValue,
|
||||
password,
|
||||
returnToUrl: globalThis.location.href,
|
||||
}
|
||||
: {
|
||||
confirmationEmail: inputValue,
|
||||
returnToUrl: globalThis.location.href,
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
|
||||
|
||||
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
|
||||
await startSsoReauthentication();
|
||||
return;
|
||||
} else if (result) {
|
||||
errorMessage =
|
||||
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
|
||||
}
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await signOutWithAudit({
|
||||
clearWorkspaceId: true,
|
||||
reason: "account_deletion",
|
||||
redirect: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to sign out after account deletion");
|
||||
}
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Prevent NextAuth automatic redirect
|
||||
clearWorkspaceId: true,
|
||||
});
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
if (isFormbricksCloud) {
|
||||
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
} else {
|
||||
@@ -201,9 +221,9 @@ export const DeleteAccountModal = ({
|
||||
id="deleteAccountConfirmation"
|
||||
name="deleteAccountConfirmation"
|
||||
/>
|
||||
{!requiresPasswordConfirmation && !isSsoIdentityConfirmationDisabled && (
|
||||
{!requiresPasswordConfirmation && (
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{t("workspace.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
|
||||
{t("workspace.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
|
||||
</p>
|
||||
)}
|
||||
{requiresPasswordConfirmation && (
|
||||
|
||||
@@ -4,6 +4,7 @@ export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed"
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
|
||||
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
|
||||
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
|
||||
export const ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
|
||||
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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");
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorCode } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -6,8 +7,9 @@ import { cache } from "@/lib/cache";
|
||||
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getUserAuthenticationData } from "@/lib/user/password";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import {
|
||||
completeAccountDeletionSsoReauthentication,
|
||||
@@ -50,6 +52,7 @@ vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
|
||||
SAML_PRODUCT: "formbricks",
|
||||
SAML_TENANT: "formbricks.com",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
@@ -103,13 +106,15 @@ const storedSamlIntent = {
|
||||
userId: samlIntent.userId,
|
||||
};
|
||||
|
||||
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
|
||||
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
|
||||
const mockRedisConsume = (value: unknown) => {
|
||||
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
|
||||
return redisEval;
|
||||
};
|
||||
|
||||
describe("account deletion SSO identity confirmation", () => {
|
||||
describe("account deletion SSO reauthentication", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
|
||||
@@ -124,7 +129,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("starts SSO identity confirmation with a signed, cached intent", async () => {
|
||||
test("starts SSO reauthentication with a signed, cached intent", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "google",
|
||||
@@ -146,45 +151,26 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
authorizationParams: {
|
||||
claims: JSON.stringify({
|
||||
id_token: {
|
||||
auth_time: {
|
||||
essential: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
login_hint: intent.email,
|
||||
prompt: "login",
|
||||
max_age: "0",
|
||||
},
|
||||
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
|
||||
provider: "google",
|
||||
});
|
||||
});
|
||||
|
||||
test("requests interactive login without freshness-only SSO authorization parameters", async () => {
|
||||
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
|
||||
|
||||
for (const identityProvider of ["google", "azuread", "openid"] as const) {
|
||||
mockGetUserAuthenticationData.mockResolvedValueOnce({
|
||||
email: intent.email,
|
||||
identityProvider,
|
||||
identityProviderAccountId: `${identityProvider}-account-id`,
|
||||
password: null,
|
||||
} as any);
|
||||
|
||||
const result = await startAccountDeletionSsoReauthentication({
|
||||
confirmationEmail: intent.email,
|
||||
returnToUrl: "/environments/env-1/settings/profile",
|
||||
userId: intent.userId,
|
||||
});
|
||||
|
||||
expect(result.authorizationParams).toEqual({
|
||||
login_hint: intent.email,
|
||||
prompt: "login",
|
||||
});
|
||||
expect(result.authorizationParams).not.toHaveProperty("claims");
|
||||
expect(result.authorizationParams).not.toHaveProperty("max_age");
|
||||
}
|
||||
});
|
||||
|
||||
test("starts GitHub SSO identity confirmation with account picker params", async () => {
|
||||
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "github",
|
||||
identityProviderAccountId: "github-account-id",
|
||||
identityProvider: "azuread",
|
||||
identityProviderAccountId: intent.providerAccountId,
|
||||
password: null,
|
||||
} as any);
|
||||
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
|
||||
@@ -196,13 +182,43 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
|
||||
expect(result.authorizationParams).toEqual({
|
||||
login: intent.email,
|
||||
prompt: "select_account",
|
||||
login_hint: intent.email,
|
||||
max_age: "0",
|
||||
prompt: "login",
|
||||
});
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.provider).toBe("azure-ad");
|
||||
});
|
||||
|
||||
test("starts SAML SSO identity confirmation with Jackson routing and ForceAuthn params", async () => {
|
||||
test("extracts reauth intents only from the expected callback URL", () => {
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(
|
||||
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
|
||||
)
|
||||
).toBe("intent-token");
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
|
||||
).toBeNull();
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(
|
||||
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
|
||||
expect(
|
||||
getAccountDeletionSsoReauthFailureRedirectUrl({
|
||||
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).toBe(
|
||||
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
|
||||
);
|
||||
});
|
||||
|
||||
test("starts SAML reauthentication with forced-authentication params", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "saml",
|
||||
@@ -229,32 +245,24 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("extracts confirmation intents only from the expected callback URL", () => {
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(
|
||||
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
|
||||
)
|
||||
).toBe("intent-token");
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
|
||||
).toBeNull();
|
||||
expect(
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(
|
||||
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "github",
|
||||
identityProviderAccountId: "github-account-id",
|
||||
password: null,
|
||||
} as any);
|
||||
|
||||
test("builds a safe profile redirect for SSO identity confirmation callback failures", () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
|
||||
expect(
|
||||
getAccountDeletionSsoReauthFailureRedirectUrl({
|
||||
intentToken: "intent-token",
|
||||
await expect(
|
||||
startAccountDeletionSsoReauthentication({
|
||||
confirmationEmail: intent.email,
|
||||
returnToUrl: "/environments/env-1/settings/profile",
|
||||
userId: intent.userId,
|
||||
})
|
||||
).toBe(
|
||||
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE}`
|
||||
);
|
||||
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to the web app URL when the return URL is unsafe", async () => {
|
||||
@@ -279,7 +287,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("does not start SSO identity confirmation for password-backed users", async () => {
|
||||
test("does not start SSO reauthentication for password-backed users", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "email",
|
||||
@@ -298,7 +306,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not start SSO identity confirmation when the confirmation email mismatches", async () => {
|
||||
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "google",
|
||||
@@ -317,7 +325,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not start SSO identity confirmation without a linked SSO provider account", async () => {
|
||||
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
|
||||
mockGetUserAuthenticationData.mockResolvedValue({
|
||||
email: intent.email,
|
||||
identityProvider: "google",
|
||||
@@ -351,49 +359,11 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
returnToUrl: "/environments/env-1/settings/profile",
|
||||
userId: intent.userId,
|
||||
})
|
||||
).rejects.toThrow("Unable to start account deletion SSO identity confirmation");
|
||||
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
|
||||
|
||||
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates a matching SSO callback before the normal SSO handler runs", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates a matching SAML callback without AuthnInstant freshness proof", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
provider: "saml",
|
||||
providerAccountId: samlIntent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
@@ -451,21 +421,11 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts GitHub callbacks because identity confirmation does not require freshness proof", async () => {
|
||||
const githubIntent = {
|
||||
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
|
||||
...intent,
|
||||
provider: "github",
|
||||
providerAccountId: "github-account-id",
|
||||
};
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(githubIntent);
|
||||
mockCache.get.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
id: githubIntent.id,
|
||||
provider: githubIntent.provider,
|
||||
providerAccountId: githubIntent.providerAccountId,
|
||||
userId: githubIntent.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -477,9 +437,9 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -500,7 +460,145 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stores a deletion marker after SSO identity confirmation", async () => {
|
||||
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects OIDC callbacks without an auth_time claim", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
id_token: jwt.sign({}, "test-secret"),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
|
||||
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates a fresh SAML callback with an AuthnInstant", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
authn_instant: createAuthnInstant(nowInSeconds),
|
||||
provider: "saml",
|
||||
providerAccountId: samlIntent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects SAML callbacks without an AuthnInstant", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
provider: "saml",
|
||||
providerAccountId: samlIntent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
|
||||
provider: "saml",
|
||||
providerAccountId: samlIntent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects stale OIDC auth_time claims", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds - 10 * 60),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects OIDC auth_time claims too far in the future", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds + 2 * 60),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stores a deletion marker after fresh SSO reauthentication", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
mockRedisConsume(storedIntent);
|
||||
@@ -508,6 +606,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
@@ -522,7 +621,32 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("stores a deletion marker after fresh SAML reauthentication", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
|
||||
mockRedisConsume(storedSamlIntent);
|
||||
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
|
||||
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
authn_instant: createAuthnInstant(nowInSeconds),
|
||||
provider: "saml",
|
||||
providerAccountId: samlIntent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
});
|
||||
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining(storedSamlIntent),
|
||||
5 * 60 * 1000
|
||||
);
|
||||
});
|
||||
|
||||
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
mockRedisConsume(storedIntent);
|
||||
@@ -531,6 +655,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
@@ -555,6 +680,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
|
||||
test("fails SSO completion when the provider account belongs to another user", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
|
||||
@@ -562,6 +688,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
@@ -574,6 +701,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
|
||||
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -586,6 +714,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
@@ -599,6 +728,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
});
|
||||
|
||||
test("fails SSO completion when the deletion marker cannot be cached", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
|
||||
mockRedisConsume(storedIntent);
|
||||
@@ -608,32 +738,35 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthentication({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow("Unable to complete account deletion SSO identity confirmation");
|
||||
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
|
||||
});
|
||||
|
||||
test("surfaces cache read failures while validating callbacks", async () => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
|
||||
|
||||
await expect(
|
||||
validateAccountDeletionSsoReauthenticationCallback({
|
||||
account: {
|
||||
id_token: createIdToken(nowInSeconds),
|
||||
provider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
type: "oauth",
|
||||
} as any,
|
||||
intentToken: "intent-token",
|
||||
})
|
||||
).rejects.toThrow("Unable to read account deletion SSO identity confirmation value");
|
||||
).rejects.toThrow("Unable to read account deletion SSO reauth value");
|
||||
});
|
||||
|
||||
test("requires a completed SSO identity confirmation marker before deleting an SSO account", async () => {
|
||||
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
|
||||
mockRedisConsume(null);
|
||||
|
||||
await expect(
|
||||
@@ -645,7 +778,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
test("consumes a valid SSO identity confirmation marker", async () => {
|
||||
test("consumes a valid SSO reauthentication marker", async () => {
|
||||
const redisEval = mockRedisConsume({
|
||||
...storedIntent,
|
||||
completedAt: Date.now(),
|
||||
@@ -674,12 +807,37 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
providerAccountId: intent.providerAccountId,
|
||||
userId: intent.userId,
|
||||
})
|
||||
).rejects.toThrow("Unable to consume account deletion SSO identity confirmation value");
|
||||
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
|
||||
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
|
||||
const redisEval = vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
...storedIntent,
|
||||
completedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
|
||||
|
||||
await expect(
|
||||
consumeAccountDeletionSsoReauthentication({
|
||||
identityProvider: "google",
|
||||
providerAccountId: intent.providerAccountId,
|
||||
userId: intent.userId,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
|
||||
arguments: [],
|
||||
keys: [expect.any(String)],
|
||||
});
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects unexpected Redis values while consuming a marker", async () => {
|
||||
mockCache.getRedisClient.mockResolvedValueOnce({
|
||||
eval: vi.fn().mockResolvedValue(42),
|
||||
@@ -691,7 +849,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
providerAccountId: intent.providerAccountId,
|
||||
userId: intent.userId,
|
||||
})
|
||||
).rejects.toThrow("Unexpected cached account deletion SSO identity confirmation value");
|
||||
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
|
||||
});
|
||||
|
||||
test("surfaces atomic Redis failures while consuming a marker", async () => {
|
||||
@@ -727,7 +885,7 @@ describe("account deletion SSO identity confirmation", () => {
|
||||
expect(mockCache.del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects an expired SSO identity confirmation marker", async () => {
|
||||
test("rejects an expired SSO reauthentication marker", async () => {
|
||||
mockRedisConsume({
|
||||
...storedIntent,
|
||||
completedAt: Date.now() - 6 * 60 * 1000,
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import "server-only";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Account } from "next-auth";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { SAML_PRODUCT, SAML_TENANT, WEBAPP_URL } from "@/lib/constants";
|
||||
import {
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getUserAuthenticationData } from "@/lib/user/password";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
@@ -26,8 +33,13 @@ import {
|
||||
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
|
||||
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
|
||||
|
||||
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
|
||||
type TAccountWithSamlAuthnInstant = Account & {
|
||||
authn_instant?: unknown;
|
||||
};
|
||||
|
||||
type TStoredAccountDeletionSsoReauthIntent = {
|
||||
id: string;
|
||||
@@ -60,6 +72,23 @@ const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
|
||||
saml: "saml",
|
||||
} as const satisfies Record<TSsoIdentityProvider, string>;
|
||||
|
||||
const OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
|
||||
"azuread",
|
||||
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
|
||||
"openid",
|
||||
]);
|
||||
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
|
||||
// destructive action without another app-controlled step-up.
|
||||
const FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "saml"]);
|
||||
// Google only returns auth_time when it is explicitly requested as an ID token claim.
|
||||
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
|
||||
id_token: {
|
||||
auth_time: {
|
||||
essential: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
|
||||
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
|
||||
|
||||
@@ -77,15 +106,28 @@ const getSsoIdentityProviderOrThrow = (
|
||||
return { provider: identityProvider, providerAccountId };
|
||||
};
|
||||
|
||||
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
|
||||
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
|
||||
logger.warn(
|
||||
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
|
||||
"Google SSO account deletion reauthentication is not enabled"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
|
||||
}
|
||||
|
||||
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
|
||||
logger.warn(
|
||||
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
|
||||
"SSO provider does not support verifiable account deletion reauthentication"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthAuthorizationParams = (
|
||||
provider: TSsoIdentityProvider,
|
||||
email: string
|
||||
): Record<string, string> => {
|
||||
// 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 {
|
||||
forceAuthn: "true",
|
||||
@@ -95,17 +137,23 @@ const getAccountDeletionSsoReauthAuthorizationParams = (
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === "github") {
|
||||
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
|
||||
if (provider === "google") {
|
||||
return {
|
||||
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
|
||||
login_hint: email,
|
||||
max_age: "0",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
login: email,
|
||||
prompt: "select_account",
|
||||
login_hint: email,
|
||||
max_age: "0",
|
||||
prompt: "login",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
login_hint: email,
|
||||
prompt: "login",
|
||||
};
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
};
|
||||
|
||||
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
|
||||
@@ -114,6 +162,14 @@ const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
|
||||
return callbackUrl.toString();
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
|
||||
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
|
||||
}
|
||||
|
||||
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
|
||||
|
||||
@@ -131,8 +187,10 @@ export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: st
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
|
||||
error,
|
||||
intentToken,
|
||||
}: {
|
||||
error: unknown;
|
||||
intentToken: string | null;
|
||||
}): string | null => {
|
||||
if (!intentToken) {
|
||||
@@ -150,11 +208,11 @@ export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
|
||||
const redirectUrl = new URL(validatedReturnToUrl);
|
||||
redirectUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
|
||||
getAccountDeletionSsoReauthErrorCode(error)
|
||||
);
|
||||
return redirectUrl.toString();
|
||||
} catch (redirectError) {
|
||||
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
|
||||
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -166,9 +224,9 @@ const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletio
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: intent.id, userId: intent.userId },
|
||||
"Failed to store SSO identity confirmation intent"
|
||||
"Failed to store SSO reauth intent"
|
||||
);
|
||||
throw new Error("Unable to start account deletion SSO identity confirmation");
|
||||
throw new Error("Unable to start account deletion SSO reauthentication");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,9 +237,9 @@ const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoRe
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: marker.id, userId: marker.userId },
|
||||
"Failed to store account deletion SSO identity confirmation marker"
|
||||
"Failed to store account deletion SSO reauth marker"
|
||||
);
|
||||
throw new Error("Unable to complete account deletion SSO identity confirmation");
|
||||
throw new Error("Unable to complete account deletion SSO reauthentication");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,19 +249,13 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
|
||||
try {
|
||||
redis = await cache.getRedisClient();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ ...logContext, error, key },
|
||||
"Failed to resolve Redis client for SSO identity confirmation cache"
|
||||
);
|
||||
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
logger.error(
|
||||
{ ...logContext, key },
|
||||
"Redis is required to atomically consume SSO identity confirmation cache value"
|
||||
);
|
||||
throw new Error("Unable to consume account deletion SSO identity confirmation value");
|
||||
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
|
||||
throw new Error("Unable to consume account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -226,19 +278,13 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
|
||||
}
|
||||
|
||||
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");
|
||||
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
|
||||
throw new Error("Unexpected cached account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
return JSON.parse(serializedValue) as TValue;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ ...logContext, error, key },
|
||||
"Failed to atomically consume SSO identity confirmation cache value"
|
||||
);
|
||||
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -247,11 +293,8 @@ const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string
|
||||
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");
|
||||
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
|
||||
throw new Error("Unable to read account deletion SSO reauth value");
|
||||
}
|
||||
|
||||
return cacheResult.data;
|
||||
@@ -336,6 +379,89 @@ const findLinkedSsoUserId = async ({
|
||||
return legacyUser?.id ?? null;
|
||||
};
|
||||
|
||||
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
|
||||
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
|
||||
|
||||
if (isTooOld || isFromTheFuture) {
|
||||
logger.warn(
|
||||
{
|
||||
...logContext,
|
||||
ageSeconds: nowInSeconds - authTimeInSeconds,
|
||||
authTimeInSeconds,
|
||||
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
|
||||
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
|
||||
},
|
||||
"SSO account deletion reauthentication timestamp is not fresh"
|
||||
);
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
|
||||
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idToken) {
|
||||
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const decodedToken = jwt.decode(idToken);
|
||||
|
||||
if (!decodedToken || typeof decodedToken === "string") {
|
||||
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const { auth_time: authTime } = decodedToken;
|
||||
|
||||
if (typeof authTime !== "number") {
|
||||
logger.warn(
|
||||
{ claimKeys: Object.keys(decodedToken), provider },
|
||||
"OIDC account deletion reauthentication callback is missing numeric auth_time"
|
||||
);
|
||||
if (provider === "google") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
|
||||
}
|
||||
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
|
||||
};
|
||||
|
||||
const assertFreshSamlAuthnInstant = (
|
||||
provider: TSsoIdentityProvider,
|
||||
account: TAccountWithSamlAuthnInstant
|
||||
) => {
|
||||
if (provider !== "saml") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof account.authn_instant !== "string") {
|
||||
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const authnInstantTimestamp = Date.parse(account.authn_instant);
|
||||
|
||||
if (Number.isNaN(authnInstantTimestamp)) {
|
||||
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
|
||||
};
|
||||
|
||||
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
assertFreshOidcAuthTime(provider, account.id_token);
|
||||
assertFreshSamlAuthnInstant(provider, account);
|
||||
};
|
||||
|
||||
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
|
||||
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
const provider = normalizeSsoProvider(intent.provider);
|
||||
@@ -344,6 +470,8 @@ const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
|
||||
return {
|
||||
intent,
|
||||
storedIntent: {
|
||||
@@ -397,6 +525,7 @@ const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
|
||||
expectedProviderAccountId: storedIntent.providerAccountId,
|
||||
provider: normalizedProvider,
|
||||
});
|
||||
assertFreshSsoAuthentication(normalizedProvider, account);
|
||||
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
|
||||
|
||||
return { intent, normalizedProvider, storedIntent };
|
||||
@@ -421,7 +550,8 @@ export const startAccountDeletionSsoReauthentication = async ({
|
||||
userAuthenticationData.identityProvider,
|
||||
userAuthenticationData.identityProviderAccountId
|
||||
);
|
||||
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
|
||||
|
||||
const intentId = crypto.randomUUID();
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
|
||||
@@ -486,7 +616,7 @@ export const completeAccountDeletionSsoReauthentication = async ({
|
||||
});
|
||||
logger.info(
|
||||
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
|
||||
"Completed account deletion SSO identity confirmation"
|
||||
"Completed account deletion SSO reauthentication"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -516,6 +646,7 @@ export const consumeAccountDeletionSsoReauthentication = async ({
|
||||
identityProvider,
|
||||
providerAccountId
|
||||
);
|
||||
assertSsoProviderSupportsFreshReauthentication(provider);
|
||||
|
||||
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
|
||||
getAccountDeletionSsoReauthMarkerKey(userId),
|
||||
|
||||
@@ -22,9 +22,9 @@ const oldUser = {
|
||||
};
|
||||
|
||||
const loadAccountDeletionModule = async ({
|
||||
dangerouslyDisableSsoConfirmation = false,
|
||||
dangerouslyDisableSsoReauth = false,
|
||||
}: {
|
||||
dangerouslyDisableSsoConfirmation?: boolean;
|
||||
dangerouslyDisableSsoReauth?: boolean;
|
||||
} = {}) => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -35,7 +35,7 @@ const loadAccountDeletionModule = async ({
|
||||
}));
|
||||
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: dangerouslyDisableSsoConfirmation,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
|
||||
}));
|
||||
|
||||
vi.doMock("@/lib/organization/service", () => ({
|
||||
@@ -81,7 +81,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
|
||||
mocks.verifyUserPassword.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("requires the completed SSO identity confirmation marker by default", async () => {
|
||||
test("requires the completed SSO reauthentication marker by default", async () => {
|
||||
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
|
||||
|
||||
await expect(
|
||||
@@ -102,9 +102,9 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
|
||||
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
|
||||
});
|
||||
|
||||
test("can dangerously bypass SSO identity confirmation for passwordless SSO users", async () => {
|
||||
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
|
||||
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
|
||||
dangerouslyDisableSsoConfirmation: true,
|
||||
dangerouslyDisableSsoReauth: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -118,7 +118,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
|
||||
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ identityProvider: "google", userId: user.id },
|
||||
"Account deletion SSO identity confirmation bypassed by environment configuration"
|
||||
"Account deletion SSO reauthentication bypassed by environment configuration"
|
||||
);
|
||||
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
|
||||
});
|
||||
@@ -131,7 +131,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
|
||||
password: "hashed-password",
|
||||
});
|
||||
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
|
||||
dangerouslyDisableSsoConfirmation: true,
|
||||
dangerouslyDisableSsoReauth: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { DISABLE_ACCOUNT_DELETION_SSO_REAUTH } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
@@ -29,10 +29,10 @@ const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail
|
||||
}
|
||||
};
|
||||
|
||||
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
|
||||
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
|
||||
|
||||
const assertAccountDeletionSsoIdentityConfirmation = async ({
|
||||
const assertAccountDeletionSsoReauthentication = async ({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
@@ -41,10 +41,10 @@ const assertAccountDeletionSsoIdentityConfirmation = async ({
|
||||
providerAccountId: string | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (canBypassSsoIdentityConfirmation(identityProvider)) {
|
||||
if (canBypassSsoReauthentication(identityProvider)) {
|
||||
logger.warn(
|
||||
{ identityProvider, userId },
|
||||
"Account deletion SSO identity confirmation bypassed by environment configuration"
|
||||
"Account deletion SSO reauthentication bypassed by environment configuration"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export const deleteUserWithAccountDeletionAuthorization = async ({
|
||||
}
|
||||
|
||||
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
await assertAccountDeletionSsoIdentityConfirmation({
|
||||
await assertAccountDeletionSsoReauthentication({
|
||||
identityProvider: userAuthenticationData.identityProvider,
|
||||
providerAccountId: userAuthenticationData.identityProviderAccountId,
|
||||
userId,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { createTag } from "@/lib/tag/service";
|
||||
import { deleteResponse, getResponseWithQuotas } from "@/lib/response/service";
|
||||
import { createTag, getTagsByWorkspaceId } from "@/lib/tag/service";
|
||||
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -173,6 +173,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetTagsByWorkspaceIdAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getTagsByWorkspaceIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetTagsByWorkspaceIdAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getTagsByWorkspaceId(parsedInput.workspaceId);
|
||||
});
|
||||
|
||||
const ZGetResponseAction = z.object({
|
||||
responseId: ZId,
|
||||
});
|
||||
@@ -196,5 +222,5 @@ export const getResponseAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
return await getResponse(parsedInput.responseId);
|
||||
return await getResponseWithQuotas(parsedInput.responseId);
|
||||
});
|
||||
|
||||
@@ -701,7 +701,7 @@ describe("authOptions", () => {
|
||||
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
|
||||
test("should complete account deletion SSO identity confirmation before finalizing sign-in", async () => {
|
||||
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
|
||||
@@ -773,7 +773,7 @@ describe("authOptions", () => {
|
||||
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
|
||||
test("should redirect account deletion SSO identity confirmation failures back to the profile page", async () => {
|
||||
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const mockHandleSsoCallback = vi.fn();
|
||||
@@ -783,15 +783,17 @@ describe("authOptions", () => {
|
||||
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
|
||||
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
|
||||
);
|
||||
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("intent-token");
|
||||
const confirmationError = new Error("SSO identity confirmation failed");
|
||||
const reauthError = new Error(
|
||||
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
|
||||
);
|
||||
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(confirmationError);
|
||||
.mockRejectedValueOnce(reauthError);
|
||||
|
||||
vi.doMock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
@@ -840,10 +842,11 @@ describe("authOptions", () => {
|
||||
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
|
||||
|
||||
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
|
||||
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
|
||||
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
|
||||
);
|
||||
|
||||
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
|
||||
error: reauthError,
|
||||
intentToken: "intent-token",
|
||||
});
|
||||
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
|
||||
|
||||
@@ -90,6 +90,40 @@ const handleCredentialsOrTokenSignIn = async ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const maybeValidateAccountDeletionSsoReauth = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: NonNullable<TSignInAccount>;
|
||||
intentToken: string | null;
|
||||
}) => {
|
||||
if (!intentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validateAccountDeletionSsoReauthenticationCallback({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
};
|
||||
|
||||
const maybeCompleteAccountDeletionSsoReauth = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: NonNullable<TSignInAccount>;
|
||||
intentToken: string | null;
|
||||
}) => {
|
||||
if (!intentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoSignIn = async ({
|
||||
account,
|
||||
callbackUrl,
|
||||
@@ -105,12 +139,7 @@ const handleEnterpriseSsoSignIn = async ({
|
||||
userEmail: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (intentToken) {
|
||||
await validateAccountDeletionSsoReauthenticationCallback({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
}
|
||||
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: user as TUser,
|
||||
@@ -119,12 +148,7 @@ const handleEnterpriseSsoSignIn = async ({
|
||||
});
|
||||
|
||||
if (result === true) {
|
||||
if (intentToken) {
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
}
|
||||
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
|
||||
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId,
|
||||
@@ -465,6 +489,7 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
} catch (error) {
|
||||
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
|
||||
error,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
|
||||
|
||||
@@ -112,12 +112,4 @@ describe("cube-config", () => {
|
||||
|
||||
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
|
||||
test("fails at env validation when CUBEJS_API_SECRET is an empty string", async () => {
|
||||
setTestEnv({
|
||||
CUBEJS_API_SECRET: "",
|
||||
});
|
||||
|
||||
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import "server-only";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ConfigurationError } from "@formbricks/types/errors";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const CUBE_CONFIGURATION_ERROR_MESSAGE =
|
||||
"Cube is not configured on this instance. Set CUBEJS_API_URL and CUBEJS_API_SECRET.";
|
||||
export const CUBE_API_TOKEN_TTL_SECONDS = 5 * 60;
|
||||
export const CUBE_QUERY_SCOPE = "xm:cube:query";
|
||||
export const DEFAULT_CUBE_JWT_AUDIENCE = "formbricks-cube";
|
||||
@@ -36,12 +39,18 @@ export const normalizeCubeApiUrl = (baseUrl: string): string => {
|
||||
return `${normalizedBaseUrl}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
export const getCubeApiCredentials = () => ({
|
||||
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
|
||||
apiSecret: env.CUBEJS_API_SECRET,
|
||||
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
|
||||
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
|
||||
});
|
||||
export const getCubeApiCredentials = () => {
|
||||
if (!env.CUBEJS_API_URL || !env.CUBEJS_API_SECRET) {
|
||||
throw new ConfigurationError(CUBE_CONFIGURATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return {
|
||||
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
|
||||
apiSecret: env.CUBEJS_API_SECRET,
|
||||
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
|
||||
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
|
||||
};
|
||||
};
|
||||
|
||||
export const createCubeApiToken = (
|
||||
apiSecret: string,
|
||||
|
||||
@@ -363,10 +363,8 @@ export const generateAIChartAction = authenticatedActionClient
|
||||
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
// Verify AI is entitled, enabled at org level, and configured at instance level.
|
||||
// Uses "smartTools" (not "dataAnalysis") because chart generation only sends the
|
||||
// Cube schema context and the user's prompt to the LLM — no response PII.
|
||||
await assertOrganizationAIConfigured(organizationId, "smartTools");
|
||||
// Verify AI is entitled, enabled at org level, and configured at instance level
|
||||
await assertOrganizationAIConfigured(organizationId, "dataAnalysis");
|
||||
|
||||
const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({
|
||||
feedbackDirectoryId: parsedInput.feedbackDirectoryId,
|
||||
|
||||
@@ -7,22 +7,18 @@ import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import {
|
||||
type TAIUnavailableActionType,
|
||||
type TAIUnavailableReason,
|
||||
getAIUnavailableAction,
|
||||
} from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface AIQuerySectionProps {
|
||||
workspaceId: string;
|
||||
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||
feedbackDirectoryId: string;
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
export function AIQuerySection({
|
||||
@@ -35,31 +31,7 @@ export function AIQuerySection({
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const translateAIUnavailableMessage = (reason: TAIUnavailableReason | undefined): string => {
|
||||
switch (reason) {
|
||||
case "not_in_plan":
|
||||
return t("workspace.analysis.charts.ai_not_in_plan");
|
||||
case "not_enabled":
|
||||
return t("workspace.analysis.charts.ai_not_enabled");
|
||||
case "instance_not_configured":
|
||||
return t("workspace.analysis.charts.ai_instance_not_configured");
|
||||
default:
|
||||
return t("workspace.analysis.charts.ai_not_available");
|
||||
}
|
||||
};
|
||||
|
||||
const translateAIUnavailableAction = (actionType: TAIUnavailableActionType): string => {
|
||||
switch (actionType) {
|
||||
case "enable_ai":
|
||||
return t("workspace.analysis.charts.ai_enable_in_settings");
|
||||
case "upgrade_plan":
|
||||
return t("workspace.analysis.charts.ai_upgrade_plan");
|
||||
}
|
||||
};
|
||||
|
||||
const aiUnavailableMessage = translateAIUnavailableMessage(aiUnavailableReason);
|
||||
const aiUnavailableAction = getAIUnavailableAction(aiUnavailableReason, workspaceId);
|
||||
const showAIDataAnalysisDisabledAlert = !isAIAvailable && aiUnavailableReason === "not_enabled";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -111,31 +83,56 @@ export function AIQuerySection({
|
||||
maxLength={2000}
|
||||
disabled={!isAIAvailable || isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
{!isAIAvailable && (
|
||||
<Alert variant="info" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
<span>{aiUnavailableMessage}</span>
|
||||
</AlertDescription>
|
||||
{aiUnavailableAction && (
|
||||
<AlertButton asChild>
|
||||
<Link href={aiUnavailableAction.href}>
|
||||
{translateAIUnavailableAction(aiUnavailableAction.type)}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
{showAIDataAnalysisDisabledAlert ? (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={!isAIAvailable || !userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("workspace.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!isAIAvailable && (
|
||||
<TooltipContent>
|
||||
{{
|
||||
not_in_plan: t("workspace.analysis.charts.ai_not_in_plan"),
|
||||
not_enabled: t("workspace.analysis.charts.ai_not_enabled"),
|
||||
instance_not_configured: t("workspace.analysis.charts.ai_instance_not_configured"),
|
||||
}[aiUnavailableReason ?? ""] ?? t("workspace.analysis.charts.ai_not_available")}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
{showAIDataAnalysisDisabledAlert && (
|
||||
<Alert variant="info" size="small">
|
||||
<span className="truncate">{t("workspace.surveys.edit.ai_data_analysis_disabled")}</span>
|
||||
<Link
|
||||
href={`/workspaces/${workspaceId}/settings/organization/general`}
|
||||
className="ml-2 inline-flex shrink-0 underline">
|
||||
Enable it in organization settings.
|
||||
</Link>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { use } from "react";
|
||||
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
@@ -21,8 +20,6 @@ interface ChartsListContentProps {
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
isAIAvailable: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
@@ -30,20 +27,11 @@ const ChartsListContent = ({
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
isAIAvailable,
|
||||
aiUnavailableReason,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return (
|
||||
<ChartsList
|
||||
charts={charts}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
isAIAvailable={isAIAvailable}
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
<ChartsList charts={charts} workspaceId={workspaceId} isReadOnly={isReadOnly} directories={directories} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,7 +75,7 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
|
||||
getConnectorsWithMappings(workspaceId),
|
||||
getOrganizationAIConfig(organization.id),
|
||||
]);
|
||||
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
|
||||
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
|
||||
const isAIAvailable = !aiUnavailableReason;
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
@@ -115,8 +103,6 @@ export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPagePro
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
isAIAvailable={isAIAvailable}
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} hasFeedbackSources={connectors.length > 0} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartsListProps {
|
||||
@@ -9,8 +8,6 @@ interface ChartsListProps {
|
||||
workspaceId: string;
|
||||
isReadOnly: boolean;
|
||||
directories: { id: string; name: string }[];
|
||||
isAIAvailable: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
}
|
||||
|
||||
export const ChartsList = async ({
|
||||
@@ -18,8 +15,6 @@ export const ChartsList = async ({
|
||||
workspaceId,
|
||||
isReadOnly,
|
||||
directories,
|
||||
isAIAvailable,
|
||||
aiUnavailableReason,
|
||||
}: Readonly<ChartsListProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -47,8 +42,6 @@ export const ChartsList = async ({
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ variant: "secondary" }}
|
||||
isAIAvailable={isAIAvailable}
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { Button, type ButtonProps } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
@@ -16,7 +15,7 @@ interface CreateChartButtonProps {
|
||||
showIcon?: boolean;
|
||||
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
export function CreateChartButton({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
@@ -14,7 +13,7 @@ export interface CreateChartDialogProps {
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
export function CreateChartDialog({
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
@@ -37,7 +36,7 @@ interface CreateChartViewProps {
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
export function CreateChartView({
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getAIUnavailableAction } from "./ai-availability";
|
||||
|
||||
describe("ai availability helpers", () => {
|
||||
test("returns the organization settings action when AI is not enabled", () => {
|
||||
expect(getAIUnavailableAction("not_enabled", "workspace-1")).toEqual({
|
||||
href: "/workspaces/workspace-1/settings/organization/general",
|
||||
type: "enable_ai",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the billing action when AI is not in the plan", () => {
|
||||
expect(getAIUnavailableAction("not_in_plan", "workspace-1")).toEqual({
|
||||
href: "/workspaces/workspace-1/settings/organization/billing",
|
||||
type: "upgrade_plan",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not return an action when the instance is not configured", () => {
|
||||
expect(getAIUnavailableAction("instance_not_configured", "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not return an action when the reason is unavailable", () => {
|
||||
expect(getAIUnavailableAction(undefined, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
|
||||
export type TAIUnavailableActionType = "enable_ai" | "upgrade_plan";
|
||||
|
||||
interface AIUnavailableAction {
|
||||
href: string;
|
||||
type: TAIUnavailableActionType;
|
||||
}
|
||||
|
||||
export const getAIUnavailableAction = (
|
||||
reason: TAIUnavailableReason | undefined,
|
||||
workspaceId: string
|
||||
): AIUnavailableAction | undefined => {
|
||||
if (reason === "not_enabled") {
|
||||
return {
|
||||
href: `/workspaces/${workspaceId}/settings/organization/general`,
|
||||
type: "enable_ai",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason === "not_in_plan") {
|
||||
return {
|
||||
href: `/workspaces/${workspaceId}/settings/organization/billing`,
|
||||
type: "upgrade_plan",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -32,7 +31,7 @@ interface AddExistingChartsDialogProps {
|
||||
existingChartIds: string[];
|
||||
onSuccess: () => void;
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
interface ChartOption {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -23,7 +22,7 @@ interface DashboardControlBarProps {
|
||||
hasChanges: boolean;
|
||||
isReadOnly: boolean;
|
||||
isAIAvailable?: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
onRefresh: () => void;
|
||||
onEditToggle: () => void;
|
||||
onSave: () => void;
|
||||
|
||||
@@ -11,7 +11,6 @@ import "react-resizable/css/styles.css";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
|
||||
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
|
||||
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
|
||||
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
|
||||
@@ -41,7 +40,7 @@ interface DashboardDetailClientProps {
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
isAIAvailable: boolean;
|
||||
aiUnavailableReason?: TAIUnavailableReason;
|
||||
aiUnavailableReason?: string;
|
||||
}
|
||||
|
||||
const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { getAIDataAnalysisUnavailableReason, getOrganizationAIConfig } from "@/lib/ai/service";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
@@ -99,7 +99,7 @@ export async function DashboardDetailPage({
|
||||
getFeedbackDirectoriesByWorkspaceId(workspaceId),
|
||||
getOrganizationAIConfig(organization.id),
|
||||
]);
|
||||
const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig);
|
||||
const aiUnavailableReason = getAIDataAnalysisUnavailableReason(aiConfig);
|
||||
const isAIAvailable = !aiUnavailableReason;
|
||||
|
||||
let dashboard;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -8,17 +6,6 @@ import {
|
||||
getFilterOperatorsForType,
|
||||
} from "./schema-definition";
|
||||
|
||||
const chartCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
const dockerCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
|
||||
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
|
||||
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
|
||||
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
|
||||
|
||||
describe("schema-definition", () => {
|
||||
describe("getFilterOperatorsForType", () => {
|
||||
test("returns string operators", () => {
|
||||
@@ -107,20 +94,5 @@ describe("schema-definition", () => {
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
|
||||
test("only exposes members present in the deployed Cube schema", () => {
|
||||
const chartCubeSchema = readChartCubeSchema();
|
||||
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
|
||||
getCubeMemberName(id)
|
||||
);
|
||||
|
||||
for (const member of exposedMembers) {
|
||||
expect(chartCubeSchema).toContain(` ${member}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Helm and Docker Cube schemas in sync", () => {
|
||||
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { storeSamlAuthnInstantFromSamlResponse } from "@/modules/ee/auth/saml/lib/authn-instant";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
interface SAMLCallbackBody {
|
||||
@@ -12,7 +14,7 @@ export const POST = async (req: Request) => {
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const { connectionController, oauthController } = jacksonInstance;
|
||||
|
||||
const formData = await req.formData();
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
@@ -28,5 +30,15 @@ export const POST = async (req: Request) => {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
try {
|
||||
await storeSamlAuthnInstantFromSamlResponse({
|
||||
connectionController,
|
||||
redirectUrl: redirect_url,
|
||||
samlResponse: SAMLResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to persist SAML AuthnInstant");
|
||||
}
|
||||
|
||||
return redirect(redirect_url);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { consumeSamlAuthnInstantForCode } from "@/modules/ee/auth/saml/lib/authn-instant";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
@@ -13,6 +15,13 @@ export const POST = async (req: Request) => {
|
||||
const formData = Object.fromEntries(body.entries());
|
||||
|
||||
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
|
||||
let authnInstant: string | null = null;
|
||||
|
||||
return Response.json(response);
|
||||
try {
|
||||
authnInstant = await consumeSamlAuthnInstantForCode(formData.code);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to consume SAML AuthnInstant");
|
||||
}
|
||||
|
||||
return Response.json(authnInstant ? { ...response, authn_instant: authnInstant } : response);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { cache } from "@/lib/cache";
|
||||
import {
|
||||
consumeSamlAuthnInstantForCode,
|
||||
getSamlAuthnInstantFromResponse,
|
||||
getSamlAuthnInstantFromXml,
|
||||
storeSamlAuthnInstantFromSamlResponse,
|
||||
} from "./authn-instant";
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
del: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml20", () => ({
|
||||
default: {
|
||||
decryptXml: vi.fn(),
|
||||
parseIssuer: vi.fn(),
|
||||
validateSignature: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson/dist/saml/x509", () => ({
|
||||
getDefaultCertificate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const saml20 = await import("@boxyhq/saml20");
|
||||
const x509 = await import("@boxyhq/saml-jackson/dist/saml/x509");
|
||||
const mockCache = vi.mocked(cache);
|
||||
const mockSaml20 = vi.mocked(saml20.default);
|
||||
const mockGetDefaultCertificate = vi.mocked(x509.getDefaultCertificate);
|
||||
const connectionController = {
|
||||
getConnections: vi.fn(),
|
||||
};
|
||||
const encodeSamlResponse = (xml: string) => Buffer.from(xml, "utf8").toString("base64");
|
||||
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
|
||||
const signedSamlResponse = `
|
||||
<saml:Assertion>
|
||||
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
|
||||
</saml:Assertion>
|
||||
`;
|
||||
|
||||
describe("SAML AuthnInstant handoff", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockGetDefaultCertificate.mockResolvedValue({
|
||||
privateKey: "sp-private-key",
|
||||
publicKey: "sp-public-key",
|
||||
});
|
||||
mockSaml20.parseIssuer.mockReturnValue("https://idp.example.com/metadata");
|
||||
mockSaml20.decryptXml.mockReturnValue({ assertion: signedSamlResponse, decrypted: true });
|
||||
mockSaml20.validateSignature.mockReturnValue(signedSamlResponse);
|
||||
connectionController.getConnections.mockResolvedValue([
|
||||
{
|
||||
idpMetadata: {
|
||||
publicKey: "trusted-public-key",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("extracts and normalizes AuthnInstant from signed SAML XML", () => {
|
||||
expect(getSamlAuthnInstantFromXml(signedSamlResponse)).toBe("2026-05-04T12:30:00.000Z");
|
||||
});
|
||||
|
||||
test("extracts AuthnInstant from the signature-validated SAML response", async () => {
|
||||
const samlResponse = `
|
||||
<samlp:Response>
|
||||
<saml:Assertion>
|
||||
<saml:AuthnStatement AuthnInstant="2026-05-04T12:00:00Z" />
|
||||
</saml:Assertion>
|
||||
</samlp:Response>
|
||||
`;
|
||||
|
||||
await expect(
|
||||
getSamlAuthnInstantFromResponse({
|
||||
connectionController: connectionController as any,
|
||||
samlResponse: encodeSamlResponse(samlResponse),
|
||||
})
|
||||
).resolves.toBe("2026-05-04T12:30:00.000Z");
|
||||
expect(mockSaml20.validateSignature).toHaveBeenCalledWith(samlResponse, "trusted-public-key", null);
|
||||
});
|
||||
|
||||
test("extracts AuthnInstant from encrypted signature-validated SAML responses", async () => {
|
||||
const encryptedSignedResponse = `
|
||||
<samlp:Response>
|
||||
<saml:EncryptedAssertion>encrypted-assertion</saml:EncryptedAssertion>
|
||||
</samlp:Response>
|
||||
`;
|
||||
const decryptedSignedResponse = `
|
||||
<samlp:Response>
|
||||
<saml:Assertion>
|
||||
<saml:AuthnStatement AuthnInstant="2026-05-04T12:45:00Z" />
|
||||
</saml:Assertion>
|
||||
</samlp:Response>
|
||||
`;
|
||||
mockSaml20.validateSignature.mockReturnValue(encryptedSignedResponse);
|
||||
mockSaml20.decryptXml.mockReturnValue({ assertion: decryptedSignedResponse, decrypted: true });
|
||||
|
||||
await expect(
|
||||
getSamlAuthnInstantFromResponse({
|
||||
connectionController: connectionController as any,
|
||||
samlResponse: encodeSamlResponse(encryptedSignedResponse),
|
||||
})
|
||||
).resolves.toBe("2026-05-04T12:45:00.000Z");
|
||||
|
||||
expect(mockGetDefaultCertificate).toHaveBeenCalled();
|
||||
expect(mockSaml20.decryptXml).toHaveBeenCalledWith(encryptedSignedResponse, {
|
||||
privateKey: "sp-private-key",
|
||||
});
|
||||
});
|
||||
|
||||
test("stores signed AuthnInstant by the one-time OAuth code from the Jackson redirect", async () => {
|
||||
const samlResponse = encodeSamlResponse(`
|
||||
<samlp:Response>
|
||||
<saml:Assertion>
|
||||
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
|
||||
</saml:Assertion>
|
||||
</samlp:Response>
|
||||
`);
|
||||
|
||||
await storeSamlAuthnInstantFromSamlResponse({
|
||||
connectionController: connectionController as any,
|
||||
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
|
||||
samlResponse,
|
||||
});
|
||||
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ authnInstant: "2026-05-04T12:30:00.000Z" },
|
||||
5 * 60 * 1000
|
||||
);
|
||||
const cacheKey = mockCache.set.mock.calls[0][0] as string;
|
||||
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
|
||||
expect(cacheKey).not.toContain("oauth-code");
|
||||
});
|
||||
|
||||
test("does not store when the signed SAML XML has no AuthnInstant", async () => {
|
||||
mockSaml20.validateSignature.mockReturnValue("<saml:Assertion />");
|
||||
|
||||
await storeSamlAuthnInstantFromSamlResponse({
|
||||
connectionController: connectionController as any,
|
||||
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
|
||||
samlResponse: encodeSamlResponse("<samlp:Response />"),
|
||||
});
|
||||
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not store when the SAML signature cannot be validated with known IdP metadata", async () => {
|
||||
mockSaml20.validateSignature.mockReturnValue(null);
|
||||
|
||||
await storeSamlAuthnInstantFromSamlResponse({
|
||||
connectionController: connectionController as any,
|
||||
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
|
||||
samlResponse: encodeSamlResponse("<samlp:Response />"),
|
||||
});
|
||||
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("consumes a stored AuthnInstant for the token response", async () => {
|
||||
mockCache.get.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
authnInstant: "2026-05-04T12:30:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(consumeSamlAuthnInstantForCode("oauth-code")).resolves.toBe("2026-05-04T12:30:00.000Z");
|
||||
|
||||
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
||||
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
|
||||
expect(cacheKey).not.toContain("oauth-code");
|
||||
expect(mockCache.del).toHaveBeenCalledWith([cacheKey]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user