mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cc477547 | |||
| 4128731c5f | |||
| ef96426ca0 | |||
| ce1dbe8b00 | |||
| 444f043140 | |||
| 2d32c0d671 | |||
| 8dc70a5e30 | |||
| 3e4e55fbf1 | |||
| fcfedd6e15 | |||
| 6c4342690f | |||
| b8c361fcf3 | |||
| 8771a0ec91 | |||
| fc33c52133 | |||
| 75cf9293b1 |
@@ -20,12 +20,12 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Cache Build
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
id: cache-build
|
||||
env:
|
||||
cache-name: prod-build
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
@@ -53,7 +53,7 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
|
||||
+37
-48
@@ -57,7 +57,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
shell: bash
|
||||
|
||||
- name: create .env
|
||||
@@ -85,65 +85,48 @@ jobs:
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
|
||||
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Install MinIO client (mc)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
|
||||
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
|
||||
MC_BIN="mc.${MC_VERSION}"
|
||||
MC_SUM="${MC_BIN}.sha256sum"
|
||||
|
||||
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
|
||||
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
|
||||
|
||||
sha256sum -c "${MC_SUM}"
|
||||
|
||||
chmod +x "${MC_BIN}"
|
||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||
|
||||
- name: Start MinIO Server
|
||||
- name: Start RustFS Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start MinIO server in background
|
||||
# Start RustFS server in background
|
||||
docker run -d \
|
||||
--name minio-server \
|
||||
--name rustfs-server \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
-e MINIO_ROOT_USER=devminio \
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
-e RUSTFS_ACCESS_KEY=devrustfs \
|
||||
-e RUSTFS_SECRET_KEY=devrustfs123 \
|
||||
-e RUSTFS_ADDRESS=:9000 \
|
||||
-e RUSTFS_CONSOLE_ENABLE=true \
|
||||
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
|
||||
rustfs/rustfs:1.0.0-alpha.93 \
|
||||
/data
|
||||
|
||||
echo "MinIO server started"
|
||||
echo "RustFS server started"
|
||||
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
- name: Bootstrap RustFS bucket and browser upload CORS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Waiting for MinIO to be ready..."
|
||||
ready=0
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
||||
echo "MinIO is up after ${i} seconds"
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "::error::MinIO did not become ready within 60 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mc alias set local http://localhost:9000 devminio devminio123
|
||||
mc mb --ignore-existing local/formbricks-e2e
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--entrypoint /bin/sh \
|
||||
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
|
||||
-e RUSTFS_ADMIN_USER=devrustfs \
|
||||
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
|
||||
-e RUSTFS_SERVICE_USER=devrustfs-service \
|
||||
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
|
||||
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
|
||||
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
|
||||
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
|
||||
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
|
||||
/tmp/rustfs-init.sh
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
@@ -242,8 +225,14 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
if-no-files-found: ignore
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: cat app.log
|
||||
run: |
|
||||
if [ -f app.log ]; then
|
||||
cat app.log
|
||||
else
|
||||
echo "app.log not found because the Run App step did not execute or failed before log creation."
|
||||
fi
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -2,6 +2,7 @@ name: Translation Validation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -49,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.2",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "7.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
|
||||
name: team.name,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
+8
-11
@@ -3,25 +3,22 @@
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
activeId: string;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisNavigation = ({
|
||||
environmentId,
|
||||
survey,
|
||||
activeId,
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
|
||||
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const url = `/environments/${environment.id}/surveys/${survey.id}`;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
|
||||
href: `${url}/summary?referer=true`,
|
||||
current: pathname?.includes("/summary"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
revalidateSurveyIdPath(environment.id, survey.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
revalidateSurveyIdPath(environment.id, survey.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,5 +1,4 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { TResponseMeta } from "@formbricks/types/responses";
|
||||
import { capitalize } from "@/lib/utils/object";
|
||||
|
||||
export const getAddressFieldLabel = (field: string, t: TFunction) => {
|
||||
switch (field) {
|
||||
|
||||
+1
-3
@@ -64,8 +64,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -76,7 +74,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
<SurveyAnalysisNavigation activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
+5
-8
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
|
||||
export const SuccessMessage = () => {
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
|
||||
+5
-9
@@ -5,14 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
@@ -23,8 +22,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
@@ -41,8 +38,6 @@ interface ModalState {
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
survey,
|
||||
environment,
|
||||
isReadOnly,
|
||||
user,
|
||||
publicDomain,
|
||||
@@ -64,7 +59,8 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { project } = useEnvironment();
|
||||
const { environment, project } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -183,7 +179,7 @@ export const SurveyAnalysisCTA = ({
|
||||
return (
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
<SurveyStatusDropdown />
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
@@ -215,7 +211,7 @@ export const SurveyAnalysisCTA = ({
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
<SuccessMessage />
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
|
||||
+18
-3
@@ -16,13 +16,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
|
||||
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
|
||||
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${iframeSrc}"
|
||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||
</iframe>
|
||||
</div>`;
|
||||
|
||||
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeBlock language="html" noMargin>
|
||||
@@ -48,6 +54,15 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
|
||||
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
|
||||
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
|
||||
<iframe
|
||||
title={t("common.preview")}
|
||||
src={previewSrc}
|
||||
className="absolute inset-0 h-full w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-3
@@ -66,8 +66,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -78,7 +76,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
<SurveyAnalysisNavigation activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
|
||||
+5
-16
@@ -3,8 +3,9 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
@@ -16,17 +17,9 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SurveyStatusDropdown = ({
|
||||
environment,
|
||||
updateLocalSurveyStatus,
|
||||
survey,
|
||||
}: SurveyStatusDropdownProps) => {
|
||||
export const SurveyStatusDropdown = () => {
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) {
|
||||
updateLocalSurveyStatus(resultingStatus);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
showReconnectButton?: boolean;
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
showReconnectButton = false,
|
||||
}: AirtableWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
airtableIntegration ? airtableIntegration.config?.key : false
|
||||
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
locale={locale}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||
/>
|
||||
) : (
|
||||
<ConnectIntegration
|
||||
|
||||
+38
-9
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
locale: TUserLocale;
|
||||
showReconnectButton: boolean;
|
||||
handleAirtableAuthorization: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
environmentId,
|
||||
setIsConnected,
|
||||
surveys,
|
||||
airtableArray,
|
||||
showReconnectButton,
|
||||
handleAirtableAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
: { isEditMode: false as const };
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
<div className="flex w-full justify-end gap-x-6">
|
||||
<div className="flex items-center">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
|
||||
<AlertButton onClick={handleAirtableAuthorization}>
|
||||
{t("environments.integrations.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="cursor-pointer text-slate-500">
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDefaultValues(null);
|
||||
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||
<div className="col-span-2 text-center">{data.elements}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+9
-1
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
try {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||
isTokenValid = false;
|
||||
}
|
||||
}
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -94,7 +94,7 @@ export const POST = async (request: Request) => {
|
||||
// Fetch with timeout of 5 seconds to prevent hanging
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, options),
|
||||
fetch(url, { ...options, redirect: "manual" }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -185,4 +185,20 @@ describe("auth route audit logging", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
|
||||
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
|
||||
const user = {
|
||||
id: "user_4",
|
||||
email: "user4@example.com",
|
||||
authFlowPurpose: "sso_recovery",
|
||||
};
|
||||
const account = { provider: "token" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
||||
|
||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
|
||||
account?.provider === "token" &&
|
||||
"authFlowPurpose" in user &&
|
||||
typeof user.authFlowPurpose === "string" &&
|
||||
user.authFlowPurpose === "sso_recovery";
|
||||
|
||||
const handler = async (req: Request, ctx: any) => {
|
||||
const eventId = req.headers.get("x-request-id") ?? undefined;
|
||||
|
||||
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
|
||||
events: {
|
||||
...baseAuthOptions.events,
|
||||
async signIn({ user, account, isNewUser }: any) {
|
||||
if (isSsoRecoveryVerificationFlow(account, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { verifySsoRelinkIntent } from "@/lib/jwt";
|
||||
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
NEXT_AUTH_SESSION_COOKIE_NAMES,
|
||||
getSessionTokenFromCookieHeader,
|
||||
} from "@/modules/auth/lib/session-cookie";
|
||||
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
|
||||
|
||||
const clearSessionCookies = (response: NextResponse) => {
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
response.cookies.set({
|
||||
name: cookieName,
|
||||
value: "",
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
secure: cookieName.startsWith("__Secure-"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
|
||||
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
|
||||
clearSessionCookies(response);
|
||||
|
||||
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
|
||||
if (!sessionToken) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSessionBySessionToken(sessionToken);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
const intentToken = url.searchParams.get("intent");
|
||||
|
||||
if (!intentToken) {
|
||||
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const callbackUrl = await completeSsoRecovery({
|
||||
intentToken,
|
||||
sessionUserId: session?.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} catch {
|
||||
try {
|
||||
const intent = verifySsoRelinkIntent(intentToken);
|
||||
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
|
||||
} catch {
|
||||
return await buildFailedRecoveryResponse(request);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
@@ -127,6 +129,114 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInputData.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInputData.meta?.url) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(responseInputData.meta.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
|
||||
};
|
||||
}
|
||||
|
||||
let decryptedSuId: string;
|
||||
try {
|
||||
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
} catch {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (decryptedSuId !== responseInputData.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (responseInputData.singleUseId !== suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response"),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
@@ -78,12 +78,16 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
const email = await getEmail(key.access_token);
|
||||
|
||||
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||
const existingData = existingIntegration?.config?.data ?? [];
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
data: existingData,
|
||||
email,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as z from "zod";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { getAirtableToken, getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
const integration = await getIntegrationByType(environmentId, "airtable");
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
// Use getAirtableToken to ensure the access token is refreshed if expired
|
||||
const freshAccessToken = await getAirtableToken(environmentId);
|
||||
const tables = await getTables(
|
||||
{ ...integration.config.key, access_token: freshAccessToken },
|
||||
baseId.data
|
||||
);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
|
||||
@@ -789,6 +789,9 @@ checksums:
|
||||
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
|
||||
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
|
||||
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
|
||||
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
|
||||
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
|
||||
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
|
||||
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
|
||||
|
||||
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
ZIntegrationAirtableBases,
|
||||
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!req.ok) {
|
||||
const body = await req.text().catch(() => "");
|
||||
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
|
||||
}
|
||||
|
||||
const res = await req.json();
|
||||
return ZIntegrationAirtableBases.parse(res);
|
||||
};
|
||||
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
|
||||
},
|
||||
});
|
||||
|
||||
if (!req.ok) {
|
||||
const body = await req.text().catch(() => "");
|
||||
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
|
||||
}
|
||||
|
||||
const res = await req.json();
|
||||
|
||||
return res;
|
||||
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
|
||||
|
||||
export const getAirtableToken = async (environmentId: string) => {
|
||||
try {
|
||||
const airtableIntegration = (await getIntegrationByType(
|
||||
environmentId,
|
||||
"airtable"
|
||||
)) as TIntegrationAirtable;
|
||||
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||
|
||||
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
||||
airtableIntegration?.config.key
|
||||
|
||||
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegration,
|
||||
TIntegrationByType,
|
||||
TIntegrationInput,
|
||||
ZIntegrationType,
|
||||
} from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
|
||||
});
|
||||
|
||||
export const getIntegrationByType = reactCache(
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
async <T extends TIntegrationInput["type"]>(
|
||||
environmentId: string,
|
||||
type: T
|
||||
): Promise<TIntegrationByType<T> | null> => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
|
||||
try {
|
||||
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
|
||||
},
|
||||
},
|
||||
});
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createSsoRelinkIntent,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
verifySsoRelinkIntent,
|
||||
verifyToken,
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
@@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1004,5 +1009,78 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSO recovery support", () => {
|
||||
test("creates verification tokens that preserve the recovery purpose", async () => {
|
||||
const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" });
|
||||
|
||||
await expect(verifyToken(token)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "sso_recovery",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults legacy verification tokens to email_verification when purpose is missing", async () => {
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET);
|
||||
|
||||
await expect(verifyToken(legacyToken)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("round-trips SSO relink intents without losing callback state", () => {
|
||||
const intent = createSsoRelinkIntent({
|
||||
userId: mockUser.id,
|
||||
email: mockUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-123",
|
||||
callbackUrl: "http://localhost:3000/invite?token=invite-token",
|
||||
});
|
||||
|
||||
expect(verifySsoRelinkIntent(intent)).toEqual({
|
||||
userId: mockUser.id,
|
||||
email: mockUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-123",
|
||||
callbackUrl: "http://localhost:3000/invite?token=invite-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects expired SSO relink intents", () => {
|
||||
const expiredIntent = jwt.sign(
|
||||
{
|
||||
userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY),
|
||||
provider: "google",
|
||||
providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY),
|
||||
callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY),
|
||||
exp: Math.floor(Date.now() / 1000) - 3600,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects tampered SSO relink intents", () => {
|
||||
const intent = createSsoRelinkIntent({
|
||||
userId: mockUser.id,
|
||||
email: mockUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-123",
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
const tamperedIntent = `${intent.slice(0, -1)}x`;
|
||||
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+108
-5
@@ -1,4 +1,4 @@
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
@@ -13,7 +13,39 @@ const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const;
|
||||
|
||||
export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number];
|
||||
|
||||
export type TVerifyTokenPayload = JwtPayload & {
|
||||
id: string;
|
||||
email: string;
|
||||
purpose: TVerificationTokenPurpose;
|
||||
};
|
||||
|
||||
type TVerificationTokenOptions = SignOptions & {
|
||||
purpose?: TVerificationTokenPurpose;
|
||||
};
|
||||
|
||||
type TSsoRelinkIntentPayload = {
|
||||
callbackUrl: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
|
||||
|
||||
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
|
||||
if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) {
|
||||
return purpose as TVerificationTokenPurpose;
|
||||
}
|
||||
|
||||
return DEFAULT_VERIFICATION_TOKEN_PURPOSE;
|
||||
};
|
||||
|
||||
export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -23,7 +55,9 @@ export const createToken = (userId: string, options = {}): string => {
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
|
||||
|
||||
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
@@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async (
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "15m",
|
||||
};
|
||||
|
||||
export const createSsoRelinkIntent = (
|
||||
payload: TSsoRelinkIntentPayload,
|
||||
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
|
||||
): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
|
||||
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY),
|
||||
},
|
||||
NEXTAUTH_SECRET,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload?.userId ||
|
||||
!payload?.email ||
|
||||
!payload?.provider ||
|
||||
!payload?.providerAccountId ||
|
||||
!payload?.callbackUrl
|
||||
) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
|
||||
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -263,7 +362,11 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
return {
|
||||
id: userData.userId,
|
||||
email: userData.userEmail,
|
||||
purpose: getVerificationTokenPurpose(payload.purpose),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIntegrationByType } from "../integration/service";
|
||||
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
|
||||
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
||||
let results: TIntegrationNotionDatabase[] = [];
|
||||
try {
|
||||
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
|
||||
const notionIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
||||
results = await fetchPages(notionIntegration.config);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
|
||||
stripeCustomerId: billing.stripeCustomerId,
|
||||
limits: billing.limits,
|
||||
usageCycleAnchor: billing.usageCycleAnchor,
|
||||
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
|
||||
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import structuredClonePolyfill from "@ungap/structured-clone";
|
||||
|
||||
const structuredCloneExport =
|
||||
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
|
||||
const structuredCloneExport = globalThis.structuredClone;
|
||||
|
||||
export { structuredCloneExport as structuredClone };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { truncateText } from "../utils/strings";
|
||||
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
|
||||
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
|
||||
let channels: TIntegrationItem[] = [];
|
||||
try {
|
||||
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
if (slackIntegration && slackIntegration.config?.key) {
|
||||
channels = await fetchChannels(slackIntegration);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,14 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
|
||||
showResponseCount: false,
|
||||
};
|
||||
|
||||
const mockBlocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
];
|
||||
|
||||
const baseSurveyProperties = {
|
||||
id: mockId,
|
||||
name: "Mock Survey",
|
||||
@@ -201,13 +209,7 @@ const baseSurveyProperties = {
|
||||
displayLimit: 3,
|
||||
welcomeCard: mockWelcomeCard,
|
||||
questions: [],
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
],
|
||||
blocks: mockBlocks as unknown as SurveyMock["blocks"],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
@@ -304,6 +306,7 @@ export const createSurveyInput: TSurveyCreateInput = {
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
...baseSurveyProperties,
|
||||
blocks: mockBlocks,
|
||||
};
|
||||
|
||||
export const updateSurveyInput: TSurvey = {
|
||||
@@ -326,6 +329,7 @@ export const updateSurveyInput: TSurvey = {
|
||||
followUps: [],
|
||||
...baseSurveyProperties,
|
||||
...commonMockProperties,
|
||||
blocks: mockBlocks,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
@@ -558,22 +559,7 @@ export const updateSurveyInternal = async (
|
||||
select: selectSurvey,
|
||||
});
|
||||
|
||||
let surveySegment: TSegment | null = null;
|
||||
if (prismaSurvey.segment) {
|
||||
surveySegment = {
|
||||
...prismaSurvey.segment,
|
||||
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
|
||||
segment: surveySegment,
|
||||
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
return modifiedSurvey;
|
||||
return transformPrismaSurvey<TSurvey>(prismaSurvey);
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating survey");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -648,8 +634,8 @@ export const createSurvey = async (
|
||||
}
|
||||
|
||||
// Validate and prepare blocks for persistence
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
@@ -773,21 +759,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
||||
});
|
||||
}
|
||||
|
||||
let surveySegment: TSegment | null = null;
|
||||
if (prismaSurvey.segment) {
|
||||
surveySegment = {
|
||||
...prismaSurvey.segment,
|
||||
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
|
||||
};
|
||||
}
|
||||
|
||||
const modifiedSurvey = {
|
||||
...prismaSurvey,
|
||||
segment: surveySegment,
|
||||
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
return modifiedSurvey as TSurvey;
|
||||
return transformPrismaSurvey<TSurvey>(prismaSurvey);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const publicUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
} as const satisfies Prisma.UserSelect;
|
||||
|
||||
export type TPublicUser = Prisma.UserGetPayload<{
|
||||
select: typeof publicUserSelect;
|
||||
}>;
|
||||
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { publicUserSelect } from "./public-user";
|
||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -47,11 +48,6 @@ describe("User Service", () => {
|
||||
locale: "en-US" as TUserLocale,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
};
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
@@ -102,8 +98,12 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
expect(result).not.toHaveProperty("password");
|
||||
expect(result).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result).not.toHaveProperty("backupCodes");
|
||||
expect(result).not.toHaveProperty("identityProviderAccountId");
|
||||
});
|
||||
|
||||
test("should return null when user not found", async () => {
|
||||
@@ -134,7 +134,7 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("User Service", () => {
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("User Service", () => {
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("User Service", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
};
|
||||
import { publicUserSelect } from "./public-user";
|
||||
|
||||
// function to retrive basic information about a user's user
|
||||
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return user;
|
||||
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
id: personId,
|
||||
},
|
||||
data: data,
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return users;
|
||||
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type DebouncedFunction<T extends (...args: any[]) => void> = ((...args: Parameters<T>) => void) & {
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => void>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): DebouncedFunction<T> => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const debounced = ((...args: Parameters<T>) => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => callback(...args), delay);
|
||||
}) as DebouncedFunction<T>;
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
export const capitalize = (value: string): string => {
|
||||
if (!value) return "";
|
||||
|
||||
return `${value.charAt(0).toUpperCase()}${value.slice(1).toLowerCase()}`;
|
||||
};
|
||||
|
||||
export const isDeepEqual = (left: unknown, right: unknown): boolean => {
|
||||
if (Object.is(left, right)) return true;
|
||||
|
||||
if (left instanceof Date && right instanceof Date) {
|
||||
return left.getTime() === right.getTime();
|
||||
}
|
||||
|
||||
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
||||
|
||||
return left.every((item, index) => isDeepEqual(item, right[index]));
|
||||
}
|
||||
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord);
|
||||
const rightKeys = Object.keys(rightRecord);
|
||||
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
|
||||
return leftKeys.every(
|
||||
(key) =>
|
||||
Object.prototype.hasOwnProperty.call(rightRecord, key) && isDeepEqual(leftRecord[key], rightRecord[key])
|
||||
);
|
||||
};
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
|
||||
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
|
||||
"reconnect_button": "Erneut verbinden",
|
||||
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Send data to your Notion database",
|
||||
"please_select_a_survey_error": "Please select a survey",
|
||||
"reconnect_button": "Reconnect",
|
||||
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
|
||||
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
|
||||
"select_at_least_one_question_error": "Please select at least one question",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "You have already connected another survey to this channel.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Envía datos a tu base de datos de Notion",
|
||||
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
|
||||
"reconnect_button": "Reconectar",
|
||||
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
|
||||
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
|
||||
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
|
||||
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
|
||||
"reconnect_button": "Reconnecter",
|
||||
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
|
||||
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
|
||||
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
|
||||
|
||||
+66
-63
@@ -63,8 +63,8 @@
|
||||
"login_with_email": "Bejelentkezés e-mail-címmel",
|
||||
"lost_access": "Elvesztette a hozzáférést?",
|
||||
"new_to_formbricks": "Új a Formbicksen?",
|
||||
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
|
||||
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
|
||||
"oauth_account_not_linked_description": "Ez az SSO-szolgáltató nincs összekapcsolva egy meglévő Formbricks-fiókkal. Jelentkezzen be az eredetileg használt módszerrel. Ha ez e-mail és jelszó páros volt, akkor először végezze el az e-mail-ellenőrzést, ha a rendszer erre kéri.",
|
||||
"oauth_account_not_linked_title": "Ezt az SSO-bejelentkezést nem sikerült összekapcsolni",
|
||||
"use_a_backup_code": "Visszaszerzési kód használata"
|
||||
},
|
||||
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
|
||||
@@ -150,8 +150,8 @@
|
||||
"bottom_right": "Jobbra lent",
|
||||
"cancel": "Mégse",
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"change_organization": "Szervezet módosítása",
|
||||
"change_workspace": "Munkaterület módosítása",
|
||||
"change_organization": "Szervezet megváltoztatása",
|
||||
"change_workspace": "Munkaterület megváltoztatása",
|
||||
"choice_n": "{{n}}. választás",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_environment": "Környezet kiválasztása",
|
||||
@@ -173,7 +173,7 @@
|
||||
"connect": "Kapcsolódás",
|
||||
"connect_formbricks": "Kapcsolódás a Formbrickshez",
|
||||
"connected": "Kapcsolódva",
|
||||
"contact": "Kapcsolat",
|
||||
"contact": "Partner",
|
||||
"contacts": "Partnerek",
|
||||
"continue": "Folytatás",
|
||||
"copied": "Másolva",
|
||||
@@ -238,7 +238,7 @@
|
||||
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"field_placeholder": "{{field}} helyőrző",
|
||||
"field_placeholder": "{{field}} helykitöltője",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
@@ -250,7 +250,7 @@
|
||||
"generate": "Előállítás",
|
||||
"go_back": "Vissza",
|
||||
"go_to_dashboard": "Ugrás a vezérlőpultra",
|
||||
"headline": "Címsor",
|
||||
"headline": "Főcím",
|
||||
"hidden": "Rejtett",
|
||||
"hidden_field": "Rejtett mező",
|
||||
"hidden_fields": "Rejtett mezők",
|
||||
@@ -272,7 +272,7 @@
|
||||
"invite": "Meghívás",
|
||||
"invite_them": "Meghívó nekik",
|
||||
"javascript_required": "JavaScript szükséges",
|
||||
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
|
||||
"javascript_required_description": "A Formbricks megfelelő működéséhez JavaScript szükséges. Engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
|
||||
"key": "Kulcs",
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
@@ -326,9 +326,9 @@
|
||||
"notifications": "Értesítések",
|
||||
"number": "Szám",
|
||||
"off": "Ki",
|
||||
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
|
||||
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
|
||||
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
|
||||
"offline_all_responses_synced": "A válasz sikeresen el lett mentve.",
|
||||
"offline_syncing_responses": "Válaszok szinkronizálása…",
|
||||
"offline_you_are_offline": "Ön nem érhető el. A válasza a böngészőjében van tárolva, és akkor lesz elmentve, ha újra elérhető lesz.",
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
@@ -341,7 +341,7 @@
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"other_placeholder": "Egyéb helyőrző",
|
||||
"other_placeholder": "Egyéb helykitöltő",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
"password": "Jelszó",
|
||||
@@ -446,7 +446,7 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_role": "Csapatszerep",
|
||||
"teams": "Csapatok",
|
||||
"terms_of_service": "Felhasználási feltételek",
|
||||
"terms_of_service": "Használati feltételek",
|
||||
"text": "Szöveg",
|
||||
"time": "Idő",
|
||||
"time_to_finish": "Idő a befejezésig",
|
||||
@@ -555,7 +555,7 @@
|
||||
"verification_email_heading": "Már majdnem kész vagyunk!",
|
||||
"verification_email_hey": "Helló 👋",
|
||||
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
|
||||
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
|
||||
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 óráig érvényes.",
|
||||
"verification_email_request_new_verification": "Új ellenőrzés kérése",
|
||||
"verification_email_subject": "Ellenőrizze az e-mail-címét a Formbricks használatához",
|
||||
"verification_email_survey_name": "Kérdőív neve",
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
|
||||
"please_select_a_survey_error": "Válasszon kérdőívet",
|
||||
"reconnect_button": "Újracsatlakozás",
|
||||
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
|
||||
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
|
||||
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
|
||||
@@ -864,16 +867,16 @@
|
||||
"created_by_third_party": "Harmadik fél által létrehozva",
|
||||
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
|
||||
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
|
||||
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
|
||||
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
|
||||
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
|
||||
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
|
||||
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
|
||||
"endpoint_bad_gateway_error": "Hibás átjáró (502): proxy- vagy átjáróhiba, a szolgáltatás nem érhető el",
|
||||
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): átjáró időtúllépés, a szolgáltatás nem érhető el",
|
||||
"endpoint_internal_server_error": "Belső kiszolgálóhiba (500): a szolgáltatás váratlan hibába ütközött",
|
||||
"endpoint_method_not_allowed_error": "A módszer nem engedélyezett (405): a végpont létezik, de nem fogad POST-kéréseket",
|
||||
"endpoint_not_found_error": "Nem található (404): a végpont nem létezik",
|
||||
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
|
||||
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
|
||||
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
|
||||
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): a szolgáltatás átmenetileg nem érhető el",
|
||||
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
|
||||
"no_triggers": "Nincsenek Triggerek",
|
||||
"no_triggers": "Nincsenek aktiválók",
|
||||
"please_check_console": "További részletekért nézze meg a konzolt",
|
||||
"please_enter_a_url": "Adjon meg egy URL-t",
|
||||
"response_created": "Válasz létrehozva",
|
||||
@@ -889,7 +892,7 @@
|
||||
"webhook_created": "Webhorog létrehozva",
|
||||
"webhook_delete_confirmation": "Biztosan törölni szeretné ezt a webhorgot? Ez le fogja állítani a jövőbeli értesítések küldését.",
|
||||
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
|
||||
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
|
||||
"webhook_name_placeholder": "Elhagyható: címkézze meg a webhorgot az egyszerű azonosításért",
|
||||
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
|
||||
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
|
||||
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
|
||||
@@ -1015,9 +1018,9 @@
|
||||
"plan_change_applied": "A csomag sikeresen frissítve.",
|
||||
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
|
||||
"plan_custom": "Egyéni",
|
||||
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
|
||||
"plan_feature_everything_in_hobby": "Minden a Hobby csomagban",
|
||||
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
|
||||
"plan_hobby": "Hobbi",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
|
||||
"plan_hobby_feature_responses": "250 válasz/hónap",
|
||||
"plan_hobby_feature_workspaces": "1 munkaterület",
|
||||
@@ -1025,11 +1028,11 @@
|
||||
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
|
||||
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
|
||||
"plan_pro_feature_workspaces": "3 munkaterület",
|
||||
"plan_scale": "Méretezés",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
|
||||
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
|
||||
"plan_scale_feature_workspaces": "5 munkaterület",
|
||||
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
|
||||
"plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
|
||||
"plan_selection_title": "Csomag kiválasztása",
|
||||
"plan_unknown": "Ismeretlen",
|
||||
"remove_branding": "Márkajel eltávolítása",
|
||||
@@ -1037,7 +1040,7 @@
|
||||
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
|
||||
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
|
||||
"status_trialing": "Próbaidőszak",
|
||||
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
|
||||
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
|
||||
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
|
||||
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
|
||||
"subscription": "Előfizetés",
|
||||
@@ -1142,17 +1145,17 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
|
||||
"ai_data_analysis_disabled_for_organization": "Az MI-adatelemzés le van tiltva ennél a szervezetnél.",
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és -elemzés (MI)",
|
||||
"ai_data_analysis_enabled_description": "Mesterséges intelligencia ahhoz, hogy többet hozzon ki az adataiból. Vezérlőpultok, diagramok, jelentések és még sok más beállítása. Az élményadatokra is kiterjed.",
|
||||
"ai_enabled": "Formbricks MI",
|
||||
"ai_enabled_description": "MI-alapú funkciók kezelése ennél a szervezetnél.",
|
||||
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
|
||||
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
|
||||
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
|
||||
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
|
||||
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
|
||||
"ai_instance_not_configured": "Az MI példányszinten van beállítva környezeti változókon keresztül. Kérje meg az adminisztrátort, hogy állítsa be az AI_PROVIDER, AI_MODEL és a hozzájuk tartozó szolgáltató hitelesítési adatait, mielőtt engedélyezné az MI-funkciókat.",
|
||||
"ai_settings_updated_successfully": "Az MI-beállítások sikeresen frissítve",
|
||||
"ai_smart_tools_disabled_for_organization": "Az MI intelligens eszközei le vannak tiltva ennél a szervezetnél.",
|
||||
"ai_smart_tools_enabled": "Intelligens funkcionalitás (MI)",
|
||||
"ai_smart_tools_enabled_description": "Mesterséges intelligencia ahhoz, hogy segítsen Önnek többet elérni kevesebb idő alatt. Soha sem érinti a Formbricks segítségével gyűjtött adatokat. Csak például a kérdőívek más nyelvekre történő fordításához kerül felhasználásra.",
|
||||
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
|
||||
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
|
||||
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
|
||||
@@ -1358,8 +1361,8 @@
|
||||
"assign": "= hozzárendelése",
|
||||
"audience": "Közönség",
|
||||
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
|
||||
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
|
||||
"auto_progress_rating_and_nps_description": "Automatikus továbblépés egy kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az „Egyéb“ opció van kiválasztva.",
|
||||
"auto_progress_rating_and_nps": "Értékelés és valós ügyfél-támogatottsági érték kérdések automatikus feldolgozása",
|
||||
"auto_progress_rating_and_nps_description": "Automatikus továbblépés az egykérdéses blokkokban. A kötelező kérdések elrejtik a „Tovább” gombot, kivéve ha az „Egyéb” van kiválasztva.",
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
@@ -1405,7 +1408,7 @@
|
||||
"caution_text": "A változtatások következetlenségekhez vezetnek",
|
||||
"change_anyway": "Változtatás mindenképp",
|
||||
"change_background": "Háttér megváltoztatása",
|
||||
"change_default": "Alapértelmezett módosítása",
|
||||
"change_default": "Alapértelmezett megváltoztatása",
|
||||
"change_question_type": "Kérdés típusának megváltoztatása",
|
||||
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
|
||||
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
|
||||
@@ -1604,7 +1607,7 @@
|
||||
"matrix_rows": "Sorok",
|
||||
"max_file_size": "Legnagyobb fájlméret",
|
||||
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
|
||||
"missing_first": "Hiányzók először",
|
||||
"missing_first": "Hiányzik az első",
|
||||
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
|
||||
"multiply": "Szorzás *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
|
||||
@@ -1612,7 +1615,7 @@
|
||||
"next_button_label": "A „Következő” gomb címkéje",
|
||||
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
|
||||
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nem találhatók kérdőívnyelvek ezen a munkaterületen. Adja hozzá egyet a kezdéshez.",
|
||||
"no_option_found": "Nem található lehetőség",
|
||||
"no_recall_items_found": "Nem találhatók visszahívási elemek",
|
||||
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
|
||||
@@ -1623,7 +1626,7 @@
|
||||
"only_people_who_match_your_targeting_can_be_surveyed": "Csak azok a személyek kérdezhetők meg, akik megfelelnek a célcsoportnak.",
|
||||
"option_idx": "{choiceIndex}. lehetőség",
|
||||
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
"optional": "Választható",
|
||||
"optional": "Elhagyható",
|
||||
"options": "Beállítások*",
|
||||
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
|
||||
@@ -1639,7 +1642,7 @@
|
||||
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
|
||||
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
|
||||
"please_specify": "Adja meg",
|
||||
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
|
||||
"present_your_survey_in_multiple_languages": "A kérdőív bemutatása több nyelven",
|
||||
"prevent_double_submission": "Kettős beküldés megakadályozása",
|
||||
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
|
||||
"progress_saved": "Folyamat elmentve",
|
||||
@@ -1708,8 +1711,8 @@
|
||||
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
|
||||
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
|
||||
"response_options": "Válasz beállításai",
|
||||
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
|
||||
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
|
||||
"reverse_order_occasionally": "Időnként fordított sorrendben",
|
||||
"reverse_order_occasionally_except_last": "Időnként fordított sorrendben, kivéve az utolsó",
|
||||
"roundness": "Kerekesség",
|
||||
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
|
||||
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
@@ -1731,7 +1734,7 @@
|
||||
"seven_points": "7 pont",
|
||||
"show_block_settings": "Blokkbeállítások megjelenítése",
|
||||
"show_button": "Gomb megjelenítése",
|
||||
"show_in_order": "Sorrendben megjelenítés",
|
||||
"show_in_order": "Megjelenítés sorrendben",
|
||||
"show_language_switch": "Nyelvválasztó megjelenítése",
|
||||
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
|
||||
"show_only_once": "Megjelenítés csak egyszer",
|
||||
@@ -1770,7 +1773,7 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
|
||||
"then": "Azután",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Ez eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Ez el fogja távolítani ezt a nyelvet és annak összes fordítását ebből a kérdőívből. Ezt a műveletet nem lehet visszavonni.",
|
||||
"three_points": "3 pont",
|
||||
"times": "alkalom",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
|
||||
@@ -1790,11 +1793,11 @@
|
||||
"upper_label": "Felső címke",
|
||||
"url_filters": "URL szűrők",
|
||||
"url_not_supported": "Az URL nem támogatott",
|
||||
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
|
||||
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
|
||||
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
|
||||
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
|
||||
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
|
||||
"validate_id_duplicate": "A {type} azonosítója már létezik a kérdésekben, rejtett mezőkben vagy változókban.",
|
||||
"validate_id_empty": "Adja meg egy {type} azonosítóját.",
|
||||
"validate_id_invalid_chars": "A {type} azonosítója nem engedélyezett. Használjon csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket.",
|
||||
"validate_id_no_spaces": "A {type} azonosítója nem tartalmazhat szóközöket. Távolítsa el a szóközöket.",
|
||||
"validate_id_reserved": "A {type} „{field}” azonosítója nem engedélyezett. Ez egy foglalt kulcsszó.",
|
||||
"validation": {
|
||||
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
|
||||
"answer_all_rows": "Válaszoljon az összes sorra",
|
||||
@@ -2135,7 +2138,7 @@
|
||||
"this_quarter": "Ez a negyedév",
|
||||
"this_year": "Ez az év",
|
||||
"time_to_complete": "Kitöltéshez szükséges idő",
|
||||
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
|
||||
"ttc_survey_tooltip": "A kérdőív megválaszolásának átlagos ideje.",
|
||||
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
|
||||
"unknown_question_type": "Ismeretlen kérdéstípus",
|
||||
"use_personal_links": "Személyes hivatkozások használata",
|
||||
@@ -2224,7 +2227,7 @@
|
||||
"languages": {
|
||||
"add_language": "Nyelv hozzáadása",
|
||||
"alias": "Álnév",
|
||||
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (választható)",
|
||||
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (elhagyható)",
|
||||
"cannot_remove_language_warning": "Nem tudja eltávolítani ezt a nyelvet, mert még mindig használatban van ezekben a kérdőívekben:",
|
||||
"conflict_between_identifier_and_alias": "Ütközés van egy hozzáadott nyelv azonosítója és az álnevei egyike között. Az álnevek és az azonosítók nem lehetnek azonosak.",
|
||||
"conflict_between_selected_alias_and_another_language": "Ütközés van a kiválasztott álnév és egy másik, ezzel az azonosítóval rendelkező nyelv között. A következetlenségek elkerülése érdekében ezzel az azonosítóval adja hozzá a nyelvet a munkaterületéhez.",
|
||||
@@ -2302,11 +2305,11 @@
|
||||
"advanced_styling_field_track_height": "Követés magassága",
|
||||
"advanced_styling_field_track_height_description": "A folyamatjelző vastagságát vezérli.",
|
||||
"advanced_styling_field_upper_label_color": "Címke színe",
|
||||
"advanced_styling_field_upper_label_color_description": "A beviteli mezők feletti kis címkék és a skálacímkék színét állítja be.",
|
||||
"advanced_styling_field_upper_label_color_description": "Kiszínezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
|
||||
"advanced_styling_field_upper_label_size": "Címke betűmérete",
|
||||
"advanced_styling_field_upper_label_size_description": "A beviteli mezők feletti kis címkék és a skálacímkék betűméretét állítja be.",
|
||||
"advanced_styling_field_upper_label_size_description": "Átméretezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
|
||||
"advanced_styling_field_upper_label_weight": "Címke betűvastagsága",
|
||||
"advanced_styling_field_upper_label_weight_description": "A címkék vékonyabbá vagy vastagabbá tételét teszi lehetővé.",
|
||||
"advanced_styling_field_upper_label_weight_description": "Vékonyabbá vagy vastagabbá teszi a címkéket.",
|
||||
"advanced_styling_section_buttons": "Gombok",
|
||||
"advanced_styling_section_headlines": "Címsorok és leírások",
|
||||
"advanced_styling_section_inputs": "Beviteli mezők",
|
||||
@@ -2643,7 +2646,7 @@
|
||||
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
|
||||
"csat_question_1_lower_label": "Nem valószínű",
|
||||
"csat_question_1_upper_label": "Nagyon valószínű",
|
||||
"csat_question_2_choice_1": "Részben elégedett",
|
||||
"csat_question_2_choice_1": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_2": "Nagyon elégedett",
|
||||
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
|
||||
"csat_question_2_choice_4": "Valamelyest elégedetlen",
|
||||
@@ -2877,10 +2880,10 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
|
||||
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
|
||||
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
|
||||
"identify_customer_goals_question_1_choice_1": "Alaposan megismerni a felhasználói bázisomat",
|
||||
"identify_customer_goals_question_1_choice_1": "A felhasználói bázisom alapos megértése",
|
||||
"identify_customer_goals_question_1_choice_2": "Felülértékesítési lehetőségek azonosítása",
|
||||
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék elkészítése",
|
||||
"identify_customer_goals_question_1_choice_4": "Világuralmat szerezni, hogy mindenki kelbimbót egyen reggelire",
|
||||
"identify_customer_goals_question_1_choice_4": "Világuralom szerezése, hogy mindenki kelbimbót egyen reggelire",
|
||||
"identify_customer_goals_question_1_headline": "Mi az elsődleges célja a(z) $[projectName] használatával?",
|
||||
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
|
||||
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
|
||||
@@ -2957,13 +2960,13 @@
|
||||
"improve_trial_conversion_question_2_button_label": "Következő",
|
||||
"improve_trial_conversion_question_2_headline": "Sajnálattal halljuk. Mi volt a legnagyobb probléma a(z) $[projectName] projekt használatával?",
|
||||
"improve_trial_conversion_question_3_button_label": "Következő",
|
||||
"improve_trial_conversion_question_3_headline": "Mit várt a(z) $[projectName] projekttől?",
|
||||
"improve_trial_conversion_question_3_headline": "Mit vár el a(z) $[projectName] projekttől?",
|
||||
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
|
||||
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
|
||||
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
|
||||
"improve_trial_conversion_question_5_button_label": "Következő",
|
||||
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
|
||||
"improve_trial_conversion_question_5_subheader": "Kérjük, ismertesse az alábbiakban:",
|
||||
"improve_trial_conversion_question_5_subheader": "Írja le az alábbiakban:",
|
||||
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
|
||||
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
|
||||
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "回答を直接Notionに送信します",
|
||||
"please_select_a_survey_error": "フォームを選択してください",
|
||||
"reconnect_button": "再接続",
|
||||
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
|
||||
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
|
||||
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
|
||||
"please_select_a_survey_error": "Selecteer een enquête",
|
||||
"reconnect_button": "Opnieuw verbinden",
|
||||
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
|
||||
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
|
||||
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
|
||||
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
|
||||
"reconnect_button": "Reconectar",
|
||||
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
|
||||
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
|
||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
|
||||
"please_select_a_survey_error": "Por favor, selecione um inquérito",
|
||||
"reconnect_button": "Voltar a ligar",
|
||||
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
|
||||
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
|
||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Trimiteți datele în baza de date Notion",
|
||||
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
|
||||
"reconnect_button": "Reconectează",
|
||||
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
|
||||
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
|
||||
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
|
||||
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
|
||||
"reconnect_button": "Переподключить",
|
||||
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
|
||||
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
|
||||
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Skicka data till din Notion-databas",
|
||||
"please_select_a_survey_error": "Vänligen välj en enkät",
|
||||
"reconnect_button": "Återanslut",
|
||||
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
|
||||
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
|
||||
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "Verilerinizi Notion veritabanınıza gönderin",
|
||||
"please_select_a_survey_error": "Lütfen bir survey seçin",
|
||||
"reconnect_button": "Yeniden Bağlan",
|
||||
"reconnect_button_description": "Entegrasyon bağlantınızın süresi doldu. Yanıtları senkronize etmeye devam etmek için lütfen yeniden bağlanın. Mevcut bağlantılarınız ve verileriniz korunacaktır.",
|
||||
"reconnect_button_tooltip": "Erişiminizi yenilemek için entegrasyonu yeniden bağlayın. Mevcut bağlantılarınız ve verileriniz korunacaktır.",
|
||||
"select_at_least_one_question_error": "Lütfen en az bir soru seçin",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "Bu kanala zaten başka bir survey bağladınız.",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
|
||||
"please_select_a_survey_error": "请选择 一个 调查",
|
||||
"reconnect_button": "重新连接",
|
||||
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
|
||||
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
|
||||
"select_at_least_one_question_error": "请选择至少 一个问题",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||
|
||||
@@ -833,6 +833,9 @@
|
||||
},
|
||||
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
|
||||
"please_select_a_survey_error": "請選取問卷",
|
||||
"reconnect_button": "重新連接",
|
||||
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
|
||||
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
|
||||
"select_at_least_one_question_error": "請選取至少一個問題",
|
||||
"slack": {
|
||||
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
||||
* @returns Validation error map keyed by element ID, or null if validation passes
|
||||
*/
|
||||
export const validateResponseData = (
|
||||
blocks: TSurveyBlock[] | undefined | null,
|
||||
blocks: unknown[] | undefined | null,
|
||||
responseData: TResponseData,
|
||||
languageCode: string = "en",
|
||||
questions?: TSurveyQuestion[] | undefined | null
|
||||
@@ -28,8 +28,8 @@ export const validateResponseData = (
|
||||
// Use blocks if available, otherwise transform questions to blocks
|
||||
let blocksToUse: TSurveyBlock[] = [];
|
||||
|
||||
if (blocks && blocks.length > 0) {
|
||||
blocksToUse = blocks;
|
||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
||||
blocksToUse = blocks as TSurveyBlock[];
|
||||
} else if (questions && questions.length > 0) {
|
||||
// Transform legacy questions format to blocks for validation
|
||||
blocksToUse = transformQuestionsToBlocks(questions, []);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
|
||||
type TQuestionWithOtherOptionValidation = {
|
||||
id: string;
|
||||
type: string;
|
||||
choices?: unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to check if a string value is a valid "other" option
|
||||
* @returns BadRequestResponse if the value exceeds the limit, undefined otherwise
|
||||
*/
|
||||
export const validateOtherOptionLength = (
|
||||
value: string,
|
||||
choices: TSurveyQuestionChoice[],
|
||||
choices: unknown[],
|
||||
questionId: string,
|
||||
language?: string
|
||||
): string | undefined => {
|
||||
// Check if this is an "other" option (not in predefined choices)
|
||||
const matchingChoice = choices.find(
|
||||
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
|
||||
(choice) =>
|
||||
typeof choice === "object" &&
|
||||
choice !== null &&
|
||||
"label" in choice &&
|
||||
typeof choice.label === "object" &&
|
||||
choice.label !== null &&
|
||||
getLocalizedValue(choice.label as Record<string, string>, language ?? "default") === value
|
||||
);
|
||||
|
||||
// If this is an "other" option with value that's too long, reject the response
|
||||
@@ -31,7 +41,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
responseLanguage,
|
||||
}: {
|
||||
responseData?: TResponseData;
|
||||
surveyQuestions: TSurveyElement[];
|
||||
surveyQuestions: TQuestionWithOtherOptionValidation[];
|
||||
responseLanguage?: string;
|
||||
}): string | undefined => {
|
||||
if (!responseData) return undefined;
|
||||
@@ -39,11 +49,9 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||
if (!question) continue;
|
||||
|
||||
const isMultiChoice =
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
const isMultiChoice = question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle";
|
||||
|
||||
if (!isMultiChoice) continue;
|
||||
if (!isMultiChoice || !question.choices) continue;
|
||||
|
||||
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
|
||||
if (error) return error;
|
||||
@@ -54,7 +62,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
|
||||
|
||||
function validateAnswer(
|
||||
answer: unknown,
|
||||
choices: TSurveyQuestionChoice[],
|
||||
choices: unknown[],
|
||||
questionId: string,
|
||||
language?: string
|
||||
): string | undefined {
|
||||
|
||||
@@ -13,6 +13,10 @@ const mockUser = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isActive: true,
|
||||
password: "$2b$12$hashedPassword",
|
||||
twoFactorSecret: "encrypted-2fa-secret",
|
||||
backupCodes: "encrypted-backup-codes",
|
||||
identityProviderAccountId: "provider-account-id",
|
||||
role: "admin",
|
||||
memberships: [{ organizationId: "org456", role: "admin" }],
|
||||
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
|
||||
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(result.data.data[0]).not.toHaveProperty("password");
|
||||
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
|
||||
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.id).toBe(mockUser.id);
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,6 +160,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.name).toBe("Updated User");
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -35,3 +36,22 @@ export const deleteSessionsByUserId = async (
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSessionBySessionToken = async (
|
||||
sessionToken: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([sessionToken, z.string().min(1)]);
|
||||
|
||||
try {
|
||||
const result = await getDbClient(tx).session.deleteMany({
|
||||
where: {
|
||||
sessionToken,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,8 +3,11 @@ import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
// Import mocked rate limiting functions
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { authOptions } from "./authOptions";
|
||||
@@ -133,6 +136,10 @@ vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/brevo", () => ({
|
||||
createBrevoCustomer: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to get the provider by id from authOptions.providers.
|
||||
function getProviderById(id: string): Provider {
|
||||
const provider = authOptions.providers.find((p) => p.options.id === id);
|
||||
@@ -315,6 +322,105 @@ describe("authOptions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("allows verified users through the token provider when the token purpose is sso_recovery", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(verifyToken).mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "sso_recovery",
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: new Date(),
|
||||
} as any);
|
||||
|
||||
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
authFlowPurpose: "sso_recovery",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("defers verification side effects for unverified users when the token purpose is sso_recovery", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(verifyToken).mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "sso_recovery",
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
|
||||
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
emailVerified: null,
|
||||
authFlowPurpose: "sso_recovery",
|
||||
})
|
||||
);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("verifies unverified users during the standard email verification flow", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(verifyToken).mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.mocked(updateUser).mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
|
||||
} as any);
|
||||
|
||||
const result = await tokenProvider.options.authorize({ token: "verify-token" }, {});
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith(mockUser.id, { emailVerified: expect.any(Date) });
|
||||
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
authFlowPurpose: "email_verification",
|
||||
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects inactive users even when the verification token is otherwise valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(verifyToken).mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: null,
|
||||
isActive: false,
|
||||
} as any);
|
||||
|
||||
await expect(tokenProvider.options.authorize({ token: "inactive-token" }, {})).rejects.toThrow(
|
||||
"Your account is currently inactive. Please contact the organization admin."
|
||||
);
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(createBrevoCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
@@ -432,6 +538,51 @@ describe("authOptions", () => {
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("should not record a completed sign-in while the recovery token is only proving inbox ownership", async () => {
|
||||
const user = {
|
||||
...mockUser,
|
||||
emailVerified: new Date(),
|
||||
authFlowPurpose: "sso_recovery",
|
||||
};
|
||||
const account = { provider: "token" } as any;
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
const result = await authOptions.callbacks.signIn({ user, account } as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("should allow an unverified recovery session through until SSO completion finishes the reclaim", async () => {
|
||||
const user = {
|
||||
...mockUser,
|
||||
emailVerified: null,
|
||||
authFlowPurpose: "sso_recovery",
|
||||
};
|
||||
const account = { provider: "token" } as any;
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
const result = await authOptions.callbacks.signIn({ user, account } as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("should finalize successful sign-in when no provider information is available", async () => {
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
|
||||
if (authOptions.callbacks?.signIn) {
|
||||
const result = await authOptions.callbacks.signIn({ user, account: undefined } as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(updateUserLastLoginAt).toHaveBeenCalledWith(user.email);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -489,6 +640,57 @@ describe("authOptions", () => {
|
||||
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
|
||||
expect(mockCapturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should finalize successful sign-in after a successful enterprise SSO callback", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
|
||||
const mockUpdateUserLastLoginAt = vi.fn();
|
||||
const mockCapturePostHogEvent = vi.fn();
|
||||
|
||||
vi.doMock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
};
|
||||
});
|
||||
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
|
||||
getSSOProviders: vi.fn(() => []),
|
||||
}));
|
||||
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
|
||||
handleSsoCallback: mockHandleSsoCallback,
|
||||
}));
|
||||
vi.doMock("@/modules/auth/lib/user", () => ({
|
||||
updateUser: vi.fn(),
|
||||
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
|
||||
}));
|
||||
vi.doMock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: mockCapturePostHogEvent,
|
||||
}));
|
||||
|
||||
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
|
||||
const user = { ...mockUser, emailVerified: new Date() };
|
||||
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
|
||||
|
||||
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
|
||||
|
||||
expect(mockHandleSsoCallback).toHaveBeenCalled();
|
||||
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Two-Factor Authentication (TOTP)", () => {
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
POSTHOG_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
|
||||
import { updateUser } from "@/modules/auth/lib/user";
|
||||
import {
|
||||
logAuthAttempt,
|
||||
logAuthEvent,
|
||||
@@ -267,12 +266,13 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Token not found");
|
||||
}
|
||||
|
||||
const { id } = await verifyToken(credentials?.token);
|
||||
user = await prisma.user.findUnique({
|
||||
const { id, purpose } = await verifyToken(credentials?.token);
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
user = foundUser ? { ...foundUser, authFlowPurpose: purpose } : null;
|
||||
} catch (e) {
|
||||
logger.error(e, "Error in CredentialsProvider authorize");
|
||||
|
||||
@@ -291,7 +291,10 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Either a user does not match the provided token or the token is invalid");
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
const authFlowPurpose = user.authFlowPurpose ?? "email_verification";
|
||||
const isSsoRecovery = authFlowPurpose === "sso_recovery";
|
||||
|
||||
if (user.emailVerified && !isSsoRecovery) {
|
||||
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
|
||||
throw new Error("Email already verified");
|
||||
}
|
||||
@@ -301,14 +304,20 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
user = await updateUser(user.id, { emailVerified: new Date() });
|
||||
if (!user.emailVerified && !isSsoRecovery) {
|
||||
const updatedUser = await updateUser(user.id, { emailVerified: new Date() });
|
||||
user = {
|
||||
...updatedUser,
|
||||
authFlowPurpose,
|
||||
};
|
||||
|
||||
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
|
||||
emailVerifiedAt: user.emailVerified,
|
||||
});
|
||||
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
|
||||
emailVerifiedAt: user.emailVerified,
|
||||
});
|
||||
|
||||
// send new user to brevo after email verification
|
||||
createBrevoCustomer({ id: user.id, email: user.email });
|
||||
// send new user to brevo after email verification
|
||||
createBrevoCustomer({ id: user.id, email: user.email });
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
@@ -339,37 +348,27 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
const userEmail = user.email ?? "";
|
||||
const userId = user.id as string;
|
||||
|
||||
// Capture sign-in event for PostHog (query BEFORE updating lastLoginAt)
|
||||
const captureSignIn = async (provider: string) => {
|
||||
if (!POSTHOG_KEY) return;
|
||||
|
||||
try {
|
||||
const [membershipCount, userData] = await Promise.all([
|
||||
prisma.membership.count({ where: { userId } }),
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { lastLoginAt: true } }),
|
||||
]);
|
||||
const isFirstLoginToday =
|
||||
userData?.lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
|
||||
|
||||
capturePostHogEvent(userId, "user_signed_in", {
|
||||
auth_provider: provider,
|
||||
organization_count: membershipCount,
|
||||
is_first_login_today: isFirstLoginToday,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error }, "Failed to capture PostHog sign-in event");
|
||||
}
|
||||
};
|
||||
const authFlowPurpose =
|
||||
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
|
||||
? user.authFlowPurpose
|
||||
: undefined;
|
||||
|
||||
if (account?.provider === "credentials" || account?.provider === "token") {
|
||||
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if user's email is verified or not
|
||||
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
|
||||
logger.error("Email Verification is Pending");
|
||||
throw new Error("Email Verification is Pending");
|
||||
}
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId,
|
||||
email: userEmail,
|
||||
provider: account.provider,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (ENTERPRISE_LICENSE_KEY && account) {
|
||||
@@ -379,14 +378,20 @@ export const authOptions: NextAuthOptions = {
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
if (result === true) {
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId,
|
||||
email: userEmail,
|
||||
provider: account.provider,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void captureSignIn(account?.provider ?? "unknown");
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId,
|
||||
email: userEmail,
|
||||
provider: account?.provider ?? "unknown",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
|
||||
"__Secure-next-auth.session-token",
|
||||
"next-auth.session-token",
|
||||
] as const;
|
||||
import { getSessionTokenFromCookieStore } from "./session-cookie";
|
||||
|
||||
type TCookieStore = {
|
||||
get: (name: string) => { value: string } | undefined;
|
||||
@@ -14,14 +10,7 @@ type TRequestWithCookies = {
|
||||
};
|
||||
|
||||
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
const cookie = request.cookies.get(cookieName);
|
||||
if (cookie?.value) {
|
||||
return cookie.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return getSessionTokenFromCookieStore(request.cookies);
|
||||
};
|
||||
|
||||
export const getProxySession = async (request: TRequestWithCookies) => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export const NEXT_AUTH_SESSION_COOKIE_NAMES = [
|
||||
"__Secure-next-auth.session-token",
|
||||
"next-auth.session-token",
|
||||
] as const;
|
||||
|
||||
type TCookieStore = {
|
||||
get: (name: string) => { value: string } | undefined;
|
||||
};
|
||||
|
||||
const getCookieValueFromHeader = (cookieHeader: string, cookieName: string): string | null => {
|
||||
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
|
||||
|
||||
for (const cookie of cookies) {
|
||||
if (!cookie.startsWith(`${cookieName}=`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cookieValue = cookie.slice(cookieName.length + 1);
|
||||
return cookieValue.length > 0 ? decodeURIComponent(cookieValue) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSessionTokenFromCookieStore = (cookieStore: TCookieStore): string | null => {
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
const cookie = cookieStore.get(cookieName);
|
||||
if (cookie?.value) {
|
||||
return cookie.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSessionTokenFromCookieHeader = (cookieHeader: string | null): string | null => {
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
const cookieValue = getCookieValueFromHeader(cookieHeader, cookieName);
|
||||
if (cookieValue) {
|
||||
return cookieValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const prismaMembershipCount = vi.fn();
|
||||
const prismaUserFindUnique = vi.fn();
|
||||
const capturePostHogEvent = vi.fn();
|
||||
const updateUserLastLoginAt = vi.fn();
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
count: prismaMembershipCount,
|
||||
},
|
||||
user: {
|
||||
findUnique: prismaUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
POSTHOG_KEY: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/user", () => ({
|
||||
updateUserLastLoginAt,
|
||||
}));
|
||||
|
||||
describe("captureSignIn", () => {
|
||||
test("returns early when PostHog is disabled", async () => {
|
||||
const { captureSignIn } = await import("./sign-in-tracking");
|
||||
|
||||
await captureSignIn({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
expect(prismaMembershipCount).not.toHaveBeenCalled();
|
||||
expect(prismaUserFindUnique).not.toHaveBeenCalled();
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalizeSuccessfulSignIn", () => {
|
||||
test("uses the previous lastLoginAt returned by the update path to avoid a second user lookup", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const membershipCount = vi.fn().mockResolvedValue(3);
|
||||
const userFindUnique = vi.fn();
|
||||
const postHogCapture = vi.fn();
|
||||
const updateLastLoginAt = vi.fn().mockResolvedValue(new Date());
|
||||
|
||||
vi.doMock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
count: membershipCount,
|
||||
},
|
||||
user: {
|
||||
findUnique: userFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.doMock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
POSTHOG_KEY: "phc_test_key",
|
||||
};
|
||||
});
|
||||
vi.doMock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: postHogCapture,
|
||||
}));
|
||||
vi.doMock("@/modules/auth/lib/user", () => ({
|
||||
updateUserLastLoginAt: updateLastLoginAt,
|
||||
}));
|
||||
|
||||
const { finalizeSuccessfulSignIn } = await import("./sign-in-tracking");
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(updateLastLoginAt).toHaveBeenCalledWith("john.doe@example.com");
|
||||
expect(membershipCount).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
expect(userFindUnique).not.toHaveBeenCalled();
|
||||
expect(postHogCapture).toHaveBeenCalledWith("user_1", "user_signed_in", {
|
||||
auth_provider: "google",
|
||||
organization_count: 3,
|
||||
is_first_login_today: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
|
||||
const getIsFirstLoginToday = (lastLoginAt: Date | null | undefined) =>
|
||||
lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const captureSignIn = async ({
|
||||
userId,
|
||||
provider,
|
||||
previousLastLoginAt,
|
||||
}: {
|
||||
userId: string;
|
||||
provider: string;
|
||||
previousLastLoginAt?: Date | null;
|
||||
}) => {
|
||||
if (!POSTHOG_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const membershipCountPromise = prisma.membership.count({ where: { userId } });
|
||||
const resolvedPreviousLastLoginAt =
|
||||
previousLastLoginAt === undefined
|
||||
? (
|
||||
await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { lastLoginAt: true },
|
||||
})
|
||||
)?.lastLoginAt
|
||||
: previousLastLoginAt;
|
||||
const membershipCount = await membershipCountPromise;
|
||||
|
||||
capturePostHogEvent(userId, "user_signed_in", {
|
||||
auth_provider: provider,
|
||||
organization_count: membershipCount,
|
||||
is_first_login_today: getIsFirstLoginToday(resolvedPreviousLastLoginAt),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error }, "Failed to capture PostHog sign-in event");
|
||||
}
|
||||
};
|
||||
|
||||
export const finalizeSuccessfulSignIn = async ({
|
||||
userId,
|
||||
email,
|
||||
provider,
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
}) => {
|
||||
const previousLastLoginAt = await updateUserLastLoginAt(email);
|
||||
void captureSignIn({ userId, provider, previousLastLoginAt });
|
||||
};
|
||||
@@ -17,6 +17,7 @@ const mockPrismaUser = {
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
user: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
@@ -29,6 +30,14 @@ vi.mock("@formbricks/database", () => ({
|
||||
describe("User Management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback({
|
||||
$queryRaw: vi.fn(),
|
||||
user: {
|
||||
update: vi.mocked(prisma.user.update),
|
||||
},
|
||||
} as any)
|
||||
);
|
||||
});
|
||||
|
||||
describe("createUser", () => {
|
||||
@@ -84,22 +93,31 @@ describe("User Management", () => {
|
||||
});
|
||||
|
||||
describe("updateUserLastLoginAt", () => {
|
||||
const mockUpdateData = { name: "Updated Name" };
|
||||
|
||||
test("updates a user successfully", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name });
|
||||
test("updates a user successfully and returns the previous login timestamp", async () => {
|
||||
const previousLastLoginAt = new Date("2025-04-16T10:00:00.000Z");
|
||||
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) =>
|
||||
callback({
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: mockUser.id, lastLoginAt: previousLastLoginAt }]),
|
||||
user: {
|
||||
update: vi.fn().mockResolvedValue({ ...mockPrismaUser, lastLoginAt: new Date() }),
|
||||
},
|
||||
} as any)
|
||||
);
|
||||
|
||||
const result = await updateUserLastLoginAt(mockUser.email);
|
||||
|
||||
expect(result).toEqual(void 0);
|
||||
expect(result).toEqual(previousLastLoginAt);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when user doesn't exist", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
|
||||
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) =>
|
||||
callback({
|
||||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
} as any)
|
||||
);
|
||||
|
||||
await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
@@ -44,15 +44,35 @@ export const updateUserLastLoginAt = async (email: string) => {
|
||||
validateInputs([email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const lockedUsers = await tx.$queryRaw<Array<{ id: string; lastLoginAt: Date | null }>>`
|
||||
SELECT "id", "lastLoginAt"
|
||||
FROM "User"
|
||||
WHERE "email" = ${email}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const lockedUser = lockedUsers[0];
|
||||
|
||||
if (!lockedUser) {
|
||||
throw new ResourceNotFoundError("email", email);
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: lockedUser.id,
|
||||
},
|
||||
data: {
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return lockedUser.lastLoginAt;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
|
||||
@@ -21,6 +21,18 @@ describe("verification link helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("builds a verification requested path that preserves SSO recovery purpose", () => {
|
||||
expect(
|
||||
buildVerificationRequestedPath({
|
||||
token: "abc123",
|
||||
callbackUrl: "http://localhost:3000/invite?token=invite-token",
|
||||
purpose: "sso_recovery",
|
||||
})
|
||||
).toBe(
|
||||
"/auth/verification-requested?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token&purpose=sso_recovery"
|
||||
);
|
||||
});
|
||||
|
||||
test("builds absolute verification links that preserve a valid callback URL", () => {
|
||||
expect(
|
||||
buildVerificationLinks({
|
||||
@@ -48,4 +60,21 @@ describe("verification link helpers", () => {
|
||||
verifyLink: "http://localhost:3000/auth/verify?token=abc123",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves SSO recovery purpose on the verification requested email link", () => {
|
||||
expect(
|
||||
buildVerificationLinks({
|
||||
token: "abc123",
|
||||
webAppUrl: WEBAPP_URL,
|
||||
callbackUrl: "http://localhost:3000/environments/test?foo=bar",
|
||||
purpose: "sso_recovery",
|
||||
verificationRequestToken: "email-token",
|
||||
})
|
||||
).toEqual({
|
||||
verificationRequestLink:
|
||||
"http://localhost:3000/auth/verification-requested?token=email-token&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar&purpose=sso_recovery",
|
||||
verifyLink:
|
||||
"http://localhost:3000/auth/verify?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
|
||||
const RELATIVE_URL_BASE = "http://localhost";
|
||||
export const VERIFICATION_REQUEST_PURPOSES = ["email_verification", "sso_recovery"] as const;
|
||||
export type TVerificationRequestPurpose = (typeof VERIFICATION_REQUEST_PURPOSES)[number];
|
||||
const DEFAULT_VERIFICATION_REQUEST_PURPOSE: TVerificationRequestPurpose = "email_verification";
|
||||
|
||||
export const buildVerificationRequestedPath = ({
|
||||
token,
|
||||
callbackUrl,
|
||||
purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE,
|
||||
}: {
|
||||
token: string;
|
||||
callbackUrl?: string | null;
|
||||
purpose?: TVerificationRequestPurpose;
|
||||
}): string => {
|
||||
const verificationRequestedUrl = new URL("/auth/verification-requested", RELATIVE_URL_BASE);
|
||||
verificationRequestedUrl.searchParams.set("token", token);
|
||||
@@ -16,6 +21,10 @@ export const buildVerificationRequestedPath = ({
|
||||
verificationRequestedUrl.searchParams.set("callbackUrl", callbackUrl);
|
||||
}
|
||||
|
||||
if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) {
|
||||
verificationRequestedUrl.searchParams.set("purpose", purpose);
|
||||
}
|
||||
|
||||
return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`;
|
||||
};
|
||||
|
||||
@@ -23,23 +32,31 @@ export const buildVerificationLinks = ({
|
||||
token,
|
||||
webAppUrl,
|
||||
callbackUrl,
|
||||
purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE,
|
||||
verificationRequestToken = token,
|
||||
}: {
|
||||
token: string;
|
||||
webAppUrl: string;
|
||||
callbackUrl?: string | null;
|
||||
purpose?: TVerificationRequestPurpose;
|
||||
verificationRequestToken?: string;
|
||||
}): { verificationRequestLink: string; verifyLink: string } => {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl);
|
||||
const verifyLink = new URL("/auth/verify", webAppUrl);
|
||||
verifyLink.searchParams.set("token", token);
|
||||
|
||||
const verificationRequestLink = new URL("/auth/verification-requested", webAppUrl);
|
||||
verificationRequestLink.searchParams.set("token", token);
|
||||
verificationRequestLink.searchParams.set("token", verificationRequestToken);
|
||||
|
||||
if (validatedCallbackUrl) {
|
||||
verifyLink.searchParams.set("callbackUrl", validatedCallbackUrl);
|
||||
verificationRequestLink.searchParams.set("callbackUrl", validatedCallbackUrl);
|
||||
}
|
||||
|
||||
if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) {
|
||||
verificationRequestLink.searchParams.set("purpose", purpose);
|
||||
}
|
||||
|
||||
return {
|
||||
verificationRequestLink: verificationRequestLink.toString(),
|
||||
verifyLink: verifyLink.toString(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifySsoRelinkIntent } from "@/lib/jwt";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
@@ -29,10 +30,18 @@ vi.mock("@/modules/email", () => ({
|
||||
sendVerificationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifySsoRelinkIntent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_type: string, _object: string, fn: Function) => fn),
|
||||
}));
|
||||
@@ -71,6 +80,9 @@ describe("resendVerificationEmailAction", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(verifySsoRelinkIntent).mockImplementation(() => {
|
||||
throw new Error("invalid");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -150,6 +162,7 @@ describe("resendVerificationEmailAction", () => {
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
...mockUser,
|
||||
callbackUrl: undefined,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -169,6 +182,7 @@ describe("resendVerificationEmailAction", () => {
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
...mockUser,
|
||||
callbackUrl: "http://localhost:3000/invite?token=invite-token",
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,6 +201,7 @@ describe("resendVerificationEmailAction", () => {
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
...mockUser,
|
||||
callbackUrl: undefined,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +220,86 @@ describe("resendVerificationEmailAction", () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should resend an SSO recovery email for a verified user", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
const verifiedUserWithLocale: NonNullable<Awaited<ReturnType<typeof getUserByEmail>>> = {
|
||||
...mockVerifiedUser,
|
||||
locale: "en-US",
|
||||
};
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale);
|
||||
vi.mocked(verifySsoRelinkIntent).mockReturnValue({
|
||||
callbackUrl: "http://localhost:3000",
|
||||
email: mockVerifiedUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider_123",
|
||||
userId: mockVerifiedUser.id,
|
||||
});
|
||||
|
||||
const result = await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: {
|
||||
...validInput,
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
id: mockVerifiedUser.id,
|
||||
email: mockVerifiedUser.email,
|
||||
locale: "en-US",
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
|
||||
purpose: "sso_recovery",
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not treat a client-supplied recovery callback as recovery without a valid intent", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
const verifiedUserWithLocale: NonNullable<Awaited<ReturnType<typeof getUserByEmail>>> = {
|
||||
...mockVerifiedUser,
|
||||
locale: "en-US",
|
||||
};
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale);
|
||||
|
||||
const result = await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: {
|
||||
...validInput,
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=forged-intent",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(sendVerificationEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should fall back to a normal verification email when the relink intent belongs to a different email", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(verifySsoRelinkIntent).mockReturnValue({
|
||||
callbackUrl: "http://localhost:3000",
|
||||
email: "other@example.com",
|
||||
provider: "google",
|
||||
providerAccountId: "provider_123",
|
||||
userId: "user_123",
|
||||
});
|
||||
|
||||
const result = await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: {
|
||||
...validInput,
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
...mockUser,
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
|
||||
purpose: "email_verification",
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
@@ -4,12 +4,15 @@ import { z } from "zod";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifySsoRelinkIntent } from "@/lib/jwt";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { TVerificationRequestPurpose } from "@/modules/auth/lib/verification-links";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { SSO_RECOVERY_COMPLETION_PATH } from "@/modules/ee/sso/lib/constants";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
|
||||
const ZResendVerificationEmailAction = z.object({
|
||||
@@ -17,6 +20,36 @@ const ZResendVerificationEmailAction = z.object({
|
||||
callbackUrl: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const getVerificationRequestPurpose = ({
|
||||
callbackUrl,
|
||||
userEmail,
|
||||
}: {
|
||||
callbackUrl?: string;
|
||||
userEmail: string;
|
||||
}): TVerificationRequestPurpose => {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
|
||||
if (!validatedCallbackUrl) {
|
||||
return "email_verification";
|
||||
}
|
||||
|
||||
const parsedCallbackUrl = new URL(validatedCallbackUrl);
|
||||
if (parsedCallbackUrl.pathname !== SSO_RECOVERY_COMPLETION_PATH) {
|
||||
return "email_verification";
|
||||
}
|
||||
|
||||
const intentToken = parsedCallbackUrl.searchParams.get("intent");
|
||||
if (!intentToken) {
|
||||
return "email_verification";
|
||||
}
|
||||
|
||||
try {
|
||||
const intent = verifySsoRelinkIntent(intentToken);
|
||||
return intent.email.toLowerCase() === userEmail.toLowerCase() ? "sso_recovery" : "email_verification";
|
||||
} catch {
|
||||
return "email_verification";
|
||||
}
|
||||
};
|
||||
|
||||
export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVerificationEmailAction).action(
|
||||
withAuditLogging("verificationEmailSent", "user", async ({ ctx, parsedInput }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
|
||||
@@ -25,7 +58,12 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", parsedInput.email);
|
||||
}
|
||||
if (user.emailVerified) {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined;
|
||||
const purpose = getVerificationRequestPurpose({
|
||||
callbackUrl: validatedCallbackUrl,
|
||||
userEmail: user.email,
|
||||
});
|
||||
if (user.emailVerified && purpose !== "sso_recovery") {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -35,7 +73,8 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
callbackUrl: getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined,
|
||||
callbackUrl: validatedCallbackUrl,
|
||||
purpose,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+4
-1
@@ -30,7 +30,10 @@ export const RequestVerificationEmail = ({ email, callbackUrl }: RequestVerifica
|
||||
|
||||
const requestVerificationEmail = async () => {
|
||||
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
|
||||
const response = await resendVerificationEmailAction({ email, callbackUrl: callbackUrl ?? undefined });
|
||||
const response = await resendVerificationEmailAction({
|
||||
email,
|
||||
callbackUrl: callbackUrl ?? undefined,
|
||||
});
|
||||
if (response?.data) {
|
||||
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
|
||||
} else {
|
||||
|
||||
@@ -52,6 +52,9 @@ export const ZAuditAction = z.enum([
|
||||
"userSignedOut",
|
||||
"passwordReset",
|
||||
"bulkCreated",
|
||||
"sso_recovery_started",
|
||||
"sso_recovery_completed",
|
||||
"sso_recovery_failed",
|
||||
]);
|
||||
export const ZActor = z.enum(["user", "api", "system"]);
|
||||
export const ZAuditStatus = z.enum(["success", "failure"]);
|
||||
|
||||
@@ -884,7 +884,11 @@ export const switchOrganizationToCloudPlan = async (input: {
|
||||
const currentPlan = resolveCloudPlanFromSubscription(subscription);
|
||||
const currentInterval = resolveSubscriptionInterval(subscription);
|
||||
|
||||
const isImmediateUpgrade = CLOUD_PLAN_LEVEL[input.targetPlan] > CLOUD_PLAN_LEVEL[currentPlan];
|
||||
// Non-standard plans (custom, unknown) don't follow the normal tier hierarchy,
|
||||
// so any switch from them to a standard plan should apply immediately.
|
||||
const isNonStandardCurrentPlan = currentPlan === "custom" || currentPlan === "unknown";
|
||||
const isImmediateUpgrade =
|
||||
isNonStandardCurrentPlan || CLOUD_PLAN_LEVEL[input.targetPlan] > CLOUD_PLAN_LEVEL[currentPlan];
|
||||
const isSameSelection = currentPlan === input.targetPlan && currentInterval === input.targetInterval;
|
||||
|
||||
if (isSameSelection) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { debounce } from "@/lib/utils/debounce";
|
||||
import { getContactsAction } from "../actions";
|
||||
import { TContactTableData, TContactWithAttributes } from "../types/contact";
|
||||
import { ContactsTable } from "./contacts-table";
|
||||
|
||||
@@ -48,10 +48,6 @@ vi.mock("@formbricks/cache", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node-fetch", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
@@ -86,6 +82,10 @@ vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
return {
|
||||
...(typeof actual === "object" && actual !== null ? actual : {}),
|
||||
IS_FORMBRICKS_CLOUD: false, // Default to self-hosted for most tests
|
||||
// Keep false so the normal instanceId + guard logic is exercised. No real
|
||||
// network calls are made: global.fetch and getInstanceId() are both mocked
|
||||
// at the top of this file, so the license server is never actually reached.
|
||||
E2E_TESTING: false,
|
||||
REVALIDATION_INTERVAL: 3600, // Example value
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
};
|
||||
@@ -107,6 +107,7 @@ describe("License Core Logic", () => {
|
||||
mockLogger.warn.mockReset();
|
||||
mockLogger.info.mockReset();
|
||||
mockLogger.debug.mockReset();
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
|
||||
// Set up default mock implementations for Result types
|
||||
// fetchLicense uses get with TCachedFetchResult wrapper + distributed lock; getPreviousResult uses get with :previous_result key
|
||||
@@ -168,7 +169,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Mock cache hit: get returns wrapped license for status key
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
@@ -191,7 +192,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should fetch license if not in FETCH_LICENSE_CACHE_KEY", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Default mocks give cache miss (get returns null)
|
||||
fetch.mockResolvedValueOnce({
|
||||
@@ -211,7 +212,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
|
||||
const mockPreviousResult = {
|
||||
@@ -247,7 +248,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
|
||||
const mockPreviousResult = {
|
||||
@@ -321,7 +322,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Cache miss -> fetch fails; no previous result (default get returns null)
|
||||
fetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
@@ -368,9 +369,11 @@ describe("License Core Logic", () => {
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.withCache.mockReset();
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
fetch.mockReset();
|
||||
|
||||
// Reset modules so the dynamic import below gets a fresh module with the new env mock
|
||||
vi.resetModules();
|
||||
// Mock the env module with empty license key
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
@@ -415,7 +418,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Cache miss -> fetch throws -> no previous result -> handleInitialFailure
|
||||
fetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
@@ -449,7 +452,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({ ok: false, status: 400 } as any);
|
||||
@@ -480,7 +483,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({ ok: false, status: 403 } as any);
|
||||
@@ -511,7 +514,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const mockLicense: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
@@ -576,7 +579,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { fetchLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const mockLicense: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
@@ -632,7 +635,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { fetchLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const mockLicense: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
@@ -688,7 +691,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { fetchLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
|
||||
@@ -730,7 +733,7 @@ describe("License Core Logic", () => {
|
||||
process.env.NEXT_PHASE = "phase-production-build";
|
||||
|
||||
const { fetchLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const result = await fetchLicense();
|
||||
|
||||
@@ -753,7 +756,7 @@ describe("License Core Logic", () => {
|
||||
}));
|
||||
|
||||
const { fetchLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
mockCache.tryLock.mockResolvedValue({ ok: true, data: false });
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
@@ -906,7 +909,7 @@ describe("License Core Logic", () => {
|
||||
// Cache miss so fetch runs; mock get for cache check
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -975,7 +978,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -1018,7 +1021,6 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
(await import("node-fetch")).default as Mock;
|
||||
|
||||
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
@@ -1067,7 +1069,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should log error when trackApiError is called (line 196-203)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Cache miss -> fetch returns 500
|
||||
const mockStatus = 500;
|
||||
@@ -1091,7 +1093,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Cache miss -> fetch returns 403
|
||||
const mockStatus = 403;
|
||||
@@ -1115,7 +1117,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should log info when trackFallbackUsage is called during grace period", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const mockPreviousResult = {
|
||||
@@ -1177,7 +1179,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should return active license state from pre-fetched active license without calling fetch", async () => {
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(mockActiveLicenseDetails);
|
||||
|
||||
@@ -1209,7 +1211,7 @@ describe("License Core Logic", () => {
|
||||
});
|
||||
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(null);
|
||||
|
||||
@@ -1226,7 +1228,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
test("should return inactive default when freshLicense is null and no previous result", async () => {
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(null);
|
||||
|
||||
@@ -1321,7 +1323,7 @@ describe("License Core Logic", () => {
|
||||
describe("fetchLicenseFresh", () => {
|
||||
test("should fetch directly from server without using cache", async () => {
|
||||
const { fetchLicenseFresh } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({
|
||||
@@ -1374,7 +1376,7 @@ describe("License Core Logic", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
const fetch = global.fetch as Mock;
|
||||
|
||||
// Cache miss so fetchLicense fetches from server
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import fetch from "node-fetch";
|
||||
import { cache as reactCache } from "react";
|
||||
import { ProxyAgent } from "undici";
|
||||
import { z } from "zod";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -17,6 +16,12 @@ import {
|
||||
TLicenseStatus,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
// Module-level ProxyAgent singleton — reused across all license fetches to avoid leaking
|
||||
// socket pools on every call (ProxyAgent owns connection pools and should not be created
|
||||
// per-request in long-lived processes).
|
||||
const _proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const _proxyDispatcher = _proxyUrl ? new ProxyAgent(_proxyUrl) : undefined;
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
CACHE: {
|
||||
@@ -358,12 +363,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
// (skip this check during E2E tests as we intentionally use null)
|
||||
if (!E2E_TESTING && !instanceId) return null;
|
||||
|
||||
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
||||
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
||||
usage: { responseCount },
|
||||
@@ -375,13 +374,11 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
|
||||
const res = await fetch(CONFIG.API.ENDPOINT, {
|
||||
body: JSON.stringify(payload),
|
||||
dispatcher: _proxyDispatcher,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
agent,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
signal: AbortSignal.timeout(CONFIG.API.TIMEOUT_MS),
|
||||
} as RequestInit & { dispatcher?: ProxyAgent });
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as { data: unknown };
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { syncSsoIdentityForUser } from "./account-linking";
|
||||
import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "./constants";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accountFindUnique: vi.fn(),
|
||||
accountDelete: vi.fn(),
|
||||
accountUpdate: vi.fn(),
|
||||
accountCreate: vi.fn(),
|
||||
userUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
account: {
|
||||
findUnique: mocks.accountFindUnique,
|
||||
delete: mocks.accountDelete,
|
||||
update: mocks.accountUpdate,
|
||||
create: mocks.accountCreate,
|
||||
},
|
||||
user: {
|
||||
update: mocks.userUpdate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("syncSsoIdentityForUser", () => {
|
||||
const account = {
|
||||
type: "oauth" as const,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
scope: "openid email profile",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback({
|
||||
account: {
|
||||
findUnique: mocks.accountFindUnique,
|
||||
delete: mocks.accountDelete,
|
||||
update: mocks.accountUpdate,
|
||||
create: mocks.accountCreate,
|
||||
},
|
||||
user: {
|
||||
update: mocks.userUpdate,
|
||||
},
|
||||
} as any)
|
||||
);
|
||||
mocks.accountFindUnique.mockResolvedValue(null);
|
||||
mocks.accountDelete.mockResolvedValue(undefined);
|
||||
mocks.accountUpdate.mockResolvedValue(undefined);
|
||||
mocks.accountCreate.mockResolvedValue(undefined);
|
||||
mocks.userUpdate.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("throws when the canonical account is already linked to a different user", async () => {
|
||||
mocks.accountFindUnique.mockResolvedValue({
|
||||
id: "account_1",
|
||||
userId: "user_2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account,
|
||||
})
|
||||
).rejects.toThrow(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
|
||||
expect(mocks.accountUpdate).not.toHaveBeenCalled();
|
||||
expect(mocks.accountCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("removes a legacy account row and refreshes the canonical account tokens when both exist", async () => {
|
||||
mocks.accountFindUnique.mockResolvedValue({
|
||||
id: "account_1",
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
await syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account,
|
||||
legacyAccountIdToNormalize: "legacy_account_1",
|
||||
});
|
||||
|
||||
expect(mocks.accountDelete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "legacy_account_1",
|
||||
},
|
||||
});
|
||||
expect(mocks.accountUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "account_1",
|
||||
},
|
||||
data: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
scope: "openid email profile",
|
||||
},
|
||||
});
|
||||
expect(mocks.userUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user_1",
|
||||
},
|
||||
data: {
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "provider-account-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reassigns a legacy account row when no canonical account exists yet", async () => {
|
||||
await syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account,
|
||||
legacyAccountIdToNormalize: "legacy_account_1",
|
||||
});
|
||||
|
||||
expect(mocks.accountUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "legacy_account_1",
|
||||
},
|
||||
data: {
|
||||
userId: "user_1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
scope: "openid email profile",
|
||||
},
|
||||
});
|
||||
expect(mocks.accountCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("wraps non-transactional calls in a prisma transaction", async () => {
|
||||
await syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account,
|
||||
});
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("uses the transaction client when one is provided", async () => {
|
||||
const txAccountFindUnique = vi.fn().mockResolvedValue({
|
||||
id: "account_1",
|
||||
userId: "user_1",
|
||||
});
|
||||
const txAccountUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const txUserUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const tx = {
|
||||
account: {
|
||||
findUnique: txAccountFindUnique,
|
||||
update: txAccountUpdate,
|
||||
},
|
||||
user: {
|
||||
update: txUserUpdate,
|
||||
},
|
||||
};
|
||||
|
||||
await syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account,
|
||||
tx: tx as any,
|
||||
});
|
||||
|
||||
expect(txAccountFindUnique).toHaveBeenCalledOnce();
|
||||
expect(txAccountUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "account_1",
|
||||
},
|
||||
data: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
scope: "openid email profile",
|
||||
},
|
||||
});
|
||||
expect(txUserUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user_1",
|
||||
},
|
||||
data: {
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "provider-account-1",
|
||||
},
|
||||
});
|
||||
expect(prisma.account.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates a canonical account when no account rows exist yet", async () => {
|
||||
await syncSsoIdentityForUser({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account: {
|
||||
...account,
|
||||
expires_at: 1234,
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.accountCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user_1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_at: 1234,
|
||||
scope: "openid email profile",
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
},
|
||||
});
|
||||
expect(mocks.userUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { IdentityProvider, Prisma } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "@/modules/ee/sso/lib/constants";
|
||||
|
||||
export const LINKED_SSO_LOOKUP_SELECT = {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
} as const;
|
||||
|
||||
export type TSsoLookupUser = Prisma.UserGetPayload<{
|
||||
select: typeof LINKED_SSO_LOOKUP_SELECT;
|
||||
}>;
|
||||
|
||||
export type TSsoAccountLinkInput = Pick<Account, "type" | "provider" | "providerAccountId"> &
|
||||
Partial<
|
||||
Pick<Account, "access_token" | "refresh_token" | "expires_at" | "scope" | "token_type" | "id_token">
|
||||
>;
|
||||
|
||||
const ACCOUNT_TOKEN_FIELDS = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"expires_at",
|
||||
"scope",
|
||||
"token_type",
|
||||
"id_token",
|
||||
] as const;
|
||||
|
||||
type TAccountTokenField = (typeof ACCOUNT_TOKEN_FIELDS)[number];
|
||||
type TAccountTokenUpdate = Partial<Pick<TSsoAccountLinkInput, TAccountTokenField>>;
|
||||
|
||||
const setAccountTokenField = <TField extends TAccountTokenField>(
|
||||
accountTokenUpdate: TAccountTokenUpdate,
|
||||
account: TSsoAccountLinkInput,
|
||||
field: TField
|
||||
) => {
|
||||
const value = account[field];
|
||||
|
||||
if (value !== undefined) {
|
||||
accountTokenUpdate[field] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const getAccountTokenUpdate = (account: TSsoAccountLinkInput): TAccountTokenUpdate => {
|
||||
const accountTokenUpdate: TAccountTokenUpdate = {};
|
||||
|
||||
for (const field of ACCOUNT_TOKEN_FIELDS) {
|
||||
setAccountTokenField(accountTokenUpdate, account, field);
|
||||
}
|
||||
|
||||
return accountTokenUpdate;
|
||||
};
|
||||
|
||||
const syncSsoIdentityForUserWithTx = async ({
|
||||
userId,
|
||||
provider,
|
||||
account,
|
||||
tx,
|
||||
legacyAccountIdToNormalize,
|
||||
}: {
|
||||
userId: string;
|
||||
provider: IdentityProvider;
|
||||
account: TSsoAccountLinkInput;
|
||||
tx: Prisma.TransactionClient;
|
||||
legacyAccountIdToNormalize?: string;
|
||||
}) => {
|
||||
const existingCanonicalAccount = await tx.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCanonicalAccount && existingCanonicalAccount.userId !== userId) {
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
if (legacyAccountIdToNormalize) {
|
||||
if (existingCanonicalAccount) {
|
||||
await tx.account.delete({
|
||||
where: {
|
||||
id: legacyAccountIdToNormalize,
|
||||
},
|
||||
});
|
||||
await tx.account.update({
|
||||
where: {
|
||||
id: existingCanonicalAccount.id,
|
||||
},
|
||||
data: getAccountTokenUpdate(account),
|
||||
});
|
||||
} else {
|
||||
await tx.account.update({
|
||||
where: {
|
||||
id: legacyAccountIdToNormalize,
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
type: account.type,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
...getAccountTokenUpdate(account),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (existingCanonicalAccount) {
|
||||
await tx.account.update({
|
||||
where: {
|
||||
id: existingCanonicalAccount.id,
|
||||
},
|
||||
data: getAccountTokenUpdate(account),
|
||||
});
|
||||
} else {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
userId,
|
||||
type: account.type,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
...getAccountTokenUpdate(account),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const syncSsoIdentityForUser = async ({
|
||||
userId,
|
||||
provider,
|
||||
account,
|
||||
tx,
|
||||
legacyAccountIdToNormalize,
|
||||
}: {
|
||||
userId: string;
|
||||
provider: IdentityProvider;
|
||||
account: TSsoAccountLinkInput;
|
||||
tx?: Prisma.TransactionClient;
|
||||
legacyAccountIdToNormalize?: string;
|
||||
}) => {
|
||||
if (tx) {
|
||||
await syncSsoIdentityForUserWithTx({
|
||||
userId,
|
||||
provider,
|
||||
account,
|
||||
tx,
|
||||
legacyAccountIdToNormalize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (transactionTx) => {
|
||||
await syncSsoIdentityForUserWithTx({
|
||||
userId,
|
||||
provider,
|
||||
account,
|
||||
tx: transactionTx,
|
||||
legacyAccountIdToNormalize,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
|
||||
export const SSO_RECOVERY_COMPLETION_PATH = "/api/auth/sso/recovery/complete";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getLegacySsoProviderAliases,
|
||||
getSsoProviderLookupCandidates,
|
||||
normalizeSsoProvider,
|
||||
} from "./provider-normalization";
|
||||
|
||||
describe("SSO provider normalization", () => {
|
||||
test("normalizes supported provider ids to canonical values", () => {
|
||||
expect(normalizeSsoProvider("google")).toBe("google");
|
||||
expect(normalizeSsoProvider("github")).toBe("github");
|
||||
expect(normalizeSsoProvider("azure-ad")).toBe("azuread");
|
||||
expect(normalizeSsoProvider("azuread")).toBe("azuread");
|
||||
expect(normalizeSsoProvider("openid")).toBe("openid");
|
||||
expect(normalizeSsoProvider("saml")).toBe("saml");
|
||||
expect(normalizeSsoProvider("unsupported")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns legacy lookup aliases for canonical providers", () => {
|
||||
expect(getLegacySsoProviderAliases("azuread")).toEqual(["azure-ad"]);
|
||||
expect(getLegacySsoProviderAliases("google")).toEqual([]);
|
||||
});
|
||||
|
||||
test("includes canonical and legacy provider ids when searching for linked accounts", () => {
|
||||
expect(getSsoProviderLookupCandidates("azuread")).toEqual(["azuread", "azure-ad"]);
|
||||
expect(getSsoProviderLookupCandidates("google")).toEqual(["google"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
|
||||
const SSO_PROVIDER_MAP = {
|
||||
google: "google",
|
||||
github: "github",
|
||||
"azure-ad": "azuread",
|
||||
azuread: "azuread",
|
||||
openid: "openid",
|
||||
saml: "saml",
|
||||
} as const satisfies Record<string, IdentityProvider>;
|
||||
|
||||
const LEGACY_SSO_PROVIDER_ALIASES: Partial<Record<IdentityProvider, string[]>> = {
|
||||
azuread: ["azure-ad"],
|
||||
};
|
||||
|
||||
const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP =>
|
||||
provider in SSO_PROVIDER_MAP;
|
||||
|
||||
export const normalizeSsoProvider = (provider: string): IdentityProvider | null => {
|
||||
const normalizedProviderKey = provider.toLowerCase();
|
||||
if (!isSupportedSsoProvider(normalizedProviderKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SSO_PROVIDER_MAP[normalizedProviderKey];
|
||||
};
|
||||
|
||||
export const getLegacySsoProviderAliases = (provider: IdentityProvider): string[] =>
|
||||
LEGACY_SSO_PROVIDER_ALIASES[provider] ?? [];
|
||||
|
||||
export const getSsoProviderLookupCandidates = (provider: string): string[] => {
|
||||
const normalizedProvider = normalizeSsoProvider(provider);
|
||||
|
||||
if (!normalizedProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [normalizedProvider, ...getLegacySsoProviderAliases(normalizedProvider)];
|
||||
};
|
||||
@@ -1,22 +1,43 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getSSOProviders } from "./providers";
|
||||
|
||||
type TSsoProvider = ReturnType<typeof getSSOProviders>[number];
|
||||
type TOidcProvider = Extract<TSsoProvider, { id: "openid" }>;
|
||||
type TSamlProvider = Extract<TSsoProvider, { id: "saml" }>;
|
||||
type TAzureProvider = Extract<TSsoProvider, { id: "azure-ad" }>;
|
||||
|
||||
const getProviderById = <TId extends TSsoProvider["id"]>(id: TId): Extract<TSsoProvider, { id: TId }> => {
|
||||
const provider = getSSOProviders().find(
|
||||
(candidate): candidate is Extract<TSsoProvider, { id: TId }> => candidate.id === id
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Provider with id ${id} not found`);
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-github-secret",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "test-azure-tenant-id",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_DISPLAY_NAME: "Test OIDC",
|
||||
OIDC_ISSUER: "https://test-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
WEBAPP_URL: "https://test-app.com",
|
||||
}));
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-github-secret",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "test-azure-tenant-id",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_DISPLAY_NAME: "Test OIDC",
|
||||
OIDC_ISSUER: "https://test-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
WEBAPP_URL: "https://test-app.com",
|
||||
};
|
||||
});
|
||||
|
||||
describe("SSO Providers", () => {
|
||||
test("should return all configured providers", () => {
|
||||
@@ -25,33 +46,91 @@ describe("SSO Providers", () => {
|
||||
});
|
||||
|
||||
test("should configure OIDC provider correctly", () => {
|
||||
const providers = getSSOProviders();
|
||||
const oidcProvider = providers[3];
|
||||
const oidcProvider = getProviderById("openid") as TOidcProvider;
|
||||
|
||||
expect(oidcProvider.id).toBe("openid");
|
||||
expect(oidcProvider.name).toBe("Test OIDC");
|
||||
expect((oidcProvider as any).clientId).toBe("test-oidc-client-id");
|
||||
expect((oidcProvider as any).clientSecret).toBe("test-oidc-client-secret");
|
||||
expect((oidcProvider as any).wellKnown).toBe("https://test-issuer.com/.well-known/openid-configuration");
|
||||
expect((oidcProvider as any).client?.id_token_signed_response_alg).toBe("RS256");
|
||||
expect(oidcProvider.clientId).toBe("test-oidc-client-id");
|
||||
expect(oidcProvider.clientSecret).toBe("test-oidc-client-secret");
|
||||
expect(oidcProvider.wellKnown).toBe("https://test-issuer.com/.well-known/openid-configuration");
|
||||
expect(oidcProvider.client?.id_token_signed_response_alg).toBe("RS256");
|
||||
expect(oidcProvider.checks).toContain("pkce");
|
||||
expect(oidcProvider.checks).toContain("state");
|
||||
});
|
||||
|
||||
test("should map the OIDC profile into the Formbricks user shape", () => {
|
||||
const oidcProvider = getProviderById("openid") as TOidcProvider;
|
||||
const oidcProfile: Parameters<NonNullable<TOidcProvider["profile"]>>[0] = {
|
||||
sub: "oidc-user-1",
|
||||
name: "OIDC User",
|
||||
email: "oidc@example.com",
|
||||
};
|
||||
|
||||
expect(oidcProvider.profile?.(oidcProfile)).toEqual({
|
||||
id: "oidc-user-1",
|
||||
name: "OIDC User",
|
||||
email: "oidc@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("should configure SAML provider correctly", () => {
|
||||
const providers = getSSOProviders();
|
||||
const samlProvider = providers[4];
|
||||
const googleProvider = providers[1];
|
||||
const samlProvider = getProviderById("saml") as TSamlProvider;
|
||||
const googleProvider = getProviderById("google");
|
||||
const azureProvider = getProviderById("azure-ad") as TAzureProvider;
|
||||
|
||||
expect(samlProvider.id).toBe("saml");
|
||||
expect(azureProvider.id).toBe("azure-ad");
|
||||
expect(samlProvider.name).toBe("BoxyHQ SAML");
|
||||
expect((samlProvider as any).version).toBe("2.0");
|
||||
expect(samlProvider.version).toBe("2.0");
|
||||
expect(samlProvider.checks).toContain("pkce");
|
||||
expect(samlProvider.checks).toContain("state");
|
||||
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
||||
expect(samlProvider.authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
||||
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
|
||||
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
|
||||
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should map the SAML profile and trim empty name parts", () => {
|
||||
const samlProvider = getProviderById("saml") as TSamlProvider;
|
||||
const samlProfile: Parameters<NonNullable<TSamlProvider["profile"]>>[0] = {
|
||||
id: "saml-user-1",
|
||||
email: "saml@example.com",
|
||||
firstName: "Saml",
|
||||
lastName: "",
|
||||
};
|
||||
|
||||
expect(samlProvider.profile?.(samlProfile)).toEqual({
|
||||
id: "saml-user-1",
|
||||
email: "saml@example.com",
|
||||
name: "Saml",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to empty Azure credentials when legacy env vars are unset", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
AZUREAD_CLIENT_ID: undefined,
|
||||
AZUREAD_CLIENT_SECRET: undefined,
|
||||
AZUREAD_TENANT_ID: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const { getSSOProviders: getProvidersWithMissingAzureEnv } = await import("./providers");
|
||||
const azureProvider = getProvidersWithMissingAzureEnv().find(
|
||||
(provider): provider is TAzureProvider => provider.id === "azure-ad"
|
||||
);
|
||||
|
||||
if (!azureProvider) {
|
||||
throw new Error("Azure provider not found");
|
||||
}
|
||||
|
||||
expect(azureProvider.id).toBe("azure-ad");
|
||||
expect(azureProvider.options.clientId).toBe("");
|
||||
expect(azureProvider.options.clientSecret).toBe("");
|
||||
expect(azureProvider.options.tenantId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { IdentityProvider, Organization, Prisma } from "@prisma/client";
|
||||
import type { IdentityProvider, Organization } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { upsertAccount } from "@/lib/account/service";
|
||||
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { verifyInviteToken } from "@/lib/jwt";
|
||||
@@ -23,49 +22,26 @@ import {
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
|
||||
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
|
||||
|
||||
const LINKED_SSO_LOOKUP_SELECT = {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
} as const;
|
||||
|
||||
const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
|
||||
|
||||
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
|
||||
await upsertAccount(
|
||||
{
|
||||
userId,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
|
||||
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
|
||||
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
|
||||
...(account.scope !== undefined ? { scope: account.scope } : {}),
|
||||
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
|
||||
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
|
||||
},
|
||||
tx
|
||||
);
|
||||
};
|
||||
import { LINKED_SSO_LOOKUP_SELECT, TSsoLookupUser, syncSsoIdentityForUser } from "./account-linking";
|
||||
import { getSsoProviderLookupCandidates, normalizeSsoProvider } from "./provider-normalization";
|
||||
import { startSsoRecovery } from "./sso-recovery";
|
||||
|
||||
const syncLinkedSsoUser = async ({
|
||||
linkedUser,
|
||||
user,
|
||||
account,
|
||||
provider,
|
||||
contextLogger,
|
||||
logSource,
|
||||
legacyAccountIdToNormalize,
|
||||
}: {
|
||||
linkedUser: Pick<TUser, "id" | "email">;
|
||||
user: TUser;
|
||||
account: Account;
|
||||
provider: IdentityProvider;
|
||||
contextLogger: ReturnType<typeof logger.withContext>;
|
||||
logSource: "account_row" | "legacy_identity_provider";
|
||||
logSource: "account_row" | "legacy_account_alias" | "legacy_identity_provider";
|
||||
legacyAccountIdToNormalize?: string;
|
||||
}) => {
|
||||
contextLogger.debug(
|
||||
{
|
||||
@@ -77,7 +53,23 @@ const syncLinkedSsoUser = async ({
|
||||
);
|
||||
|
||||
if (linkedUser.email === user.email) {
|
||||
await syncSsoAccount(linkedUser.id, account);
|
||||
await syncSsoIdentityForUser({
|
||||
userId: linkedUser.id,
|
||||
provider,
|
||||
account: {
|
||||
type: account.type,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
scope: account.scope,
|
||||
token_type: account.token_type,
|
||||
id_token: account.id_token,
|
||||
},
|
||||
legacyAccountIdToNormalize,
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{ linkedUserId: linkedUser.id, logSource },
|
||||
"SSO callback successful: linked user, email matches"
|
||||
@@ -98,8 +90,35 @@ const syncLinkedSsoUser = async ({
|
||||
"No other user with this email found, updating linked user email after SSO provider change"
|
||||
);
|
||||
|
||||
await updateUser(linkedUser.id, { email: user.email });
|
||||
await syncSsoAccount(linkedUser.id, account);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: linkedUser.id,
|
||||
},
|
||||
data: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
await syncSsoIdentityForUser({
|
||||
userId: linkedUser.id,
|
||||
provider,
|
||||
account: {
|
||||
type: account.type,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
scope: account.scope,
|
||||
token_type: account.token_type,
|
||||
id_token: account.id_token,
|
||||
},
|
||||
tx,
|
||||
legacyAccountIdToNormalize,
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -113,6 +132,332 @@ const syncLinkedSsoUser = async ({
|
||||
);
|
||||
};
|
||||
|
||||
const findLinkedSsoUser = async ({
|
||||
provider,
|
||||
providerAccountId,
|
||||
}: {
|
||||
provider: IdentityProvider;
|
||||
providerAccountId: string;
|
||||
}): Promise<{
|
||||
linkedUser: TSsoLookupUser;
|
||||
logSource: "account_row" | "legacy_account_alias";
|
||||
legacyAccountIdToNormalize?: string;
|
||||
} | null> => {
|
||||
const lookupCandidates = getSsoProviderLookupCandidates(provider);
|
||||
|
||||
for (const lookupProvider of lookupCandidates) {
|
||||
const existingLinkedAccount = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: lookupProvider,
|
||||
providerAccountId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
provider: true,
|
||||
user: {
|
||||
select: LINKED_SSO_LOOKUP_SELECT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingLinkedAccount?.user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingLinkedAccount.provider === provider) {
|
||||
return {
|
||||
linkedUser: existingLinkedAccount.user,
|
||||
logSource: "account_row",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
linkedUser: existingLinkedAccount.user,
|
||||
logSource: "legacy_account_alias",
|
||||
legacyAccountIdToNormalize: existingLinkedAccount.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findLegacyExactMatch = async ({
|
||||
provider,
|
||||
providerAccountId,
|
||||
}: {
|
||||
provider: IdentityProvider;
|
||||
providerAccountId: string;
|
||||
}) =>
|
||||
prisma.user.findFirst({
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: providerAccountId,
|
||||
},
|
||||
select: LINKED_SSO_LOOKUP_SELECT,
|
||||
});
|
||||
|
||||
const provisionNewSsoUser = async ({
|
||||
user,
|
||||
account,
|
||||
provider,
|
||||
callbackUrl,
|
||||
contextLogger,
|
||||
}: {
|
||||
user: TUser;
|
||||
account: Account;
|
||||
provider: IdentityProvider;
|
||||
callbackUrl: string;
|
||||
contextLogger: ReturnType<typeof logger.withContext>;
|
||||
}) => {
|
||||
let userName = user.name;
|
||||
|
||||
if (provider === "openid") {
|
||||
const oidcUser = user as TUser & TOidcNameFields;
|
||||
if (oidcUser.name) {
|
||||
userName = oidcUser.name;
|
||||
} else if (oidcUser.given_name || oidcUser.family_name) {
|
||||
userName = `${oidcUser.given_name} ${oidcUser.family_name}`;
|
||||
} else if (oidcUser.preferred_username) {
|
||||
userName = oidcUser.preferred_username;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!oidcUser.name,
|
||||
hasGivenName: !!oidcUser.given_name,
|
||||
hasFamilyName: !!oidcUser.family_name,
|
||||
hasPreferredUsername: !!oidcUser.preferred_username,
|
||||
},
|
||||
"Extracted OIDC user name"
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "saml") {
|
||||
const samlUser = user as TUser & TSamlNameFields;
|
||||
if (samlUser.name) {
|
||||
userName = samlUser.name;
|
||||
} else if (samlUser.firstName || samlUser.lastName) {
|
||||
userName = `${samlUser.firstName} ${samlUser.lastName}`;
|
||||
}
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!samlUser.name,
|
||||
hasFirstName: !!samlUser.firstName,
|
||||
hasLastName: !!samlUser.lastName,
|
||||
},
|
||||
"Extracted SAML user name"
|
||||
);
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const isFirstUser = await getIsFreshInstance();
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
isMultiOrgEnabled,
|
||||
isFirstUser,
|
||||
skipInviteForSso: SKIP_INVITE_FOR_SSO,
|
||||
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
|
||||
},
|
||||
"License and instance configuration checked"
|
||||
);
|
||||
|
||||
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
|
||||
if (!callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{ reason: "missing_callback_url" },
|
||||
"SSO callback rejected: missing callback URL for invite validation"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const isValidCallbackUrl = new URL(callbackUrl);
|
||||
const inviteToken = isValidCallbackUrl.searchParams.get("token") || "";
|
||||
const source = isValidCallbackUrl.searchParams.get("source") || "";
|
||||
|
||||
if (source === "signin" && !inviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "signin_without_invite_token" },
|
||||
"SSO callback rejected: signin without invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { email, inviteId } = verifyInviteToken(inviteToken);
|
||||
if (email !== user.email) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invite_email_mismatch", inviteId },
|
||||
"SSO callback rejected: invite token email mismatch"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isValidInviteToken = await getIsValidInviteToken(inviteId);
|
||||
if (!isValidInviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invalid_invite_token", inviteId },
|
||||
"SSO callback rejected: invalid or expired invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
contextLogger.debug({ inviteId }, "Invite token validation successful");
|
||||
} catch (err) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "invite_token_validation_error",
|
||||
error: err instanceof Error ? err.message : "unknown_error",
|
||||
},
|
||||
"SSO callback rejected: invite token validation failed"
|
||||
);
|
||||
contextLogger.error(err, "Invalid callbackUrl");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let organization: Organization | null = null;
|
||||
|
||||
if (!isFirstUser && !isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
|
||||
},
|
||||
"Determining organization assignment"
|
||||
);
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
|
||||
} else {
|
||||
organization = await getFirstOrganization();
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
contextLogger.debug(
|
||||
{ reason: "no_organization_found" },
|
||||
"SSO callback rejected: no organization found for assignment"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
if (!isAccessControlAllowed && !callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "insufficient_role_permissions",
|
||||
organizationId: organization.id,
|
||||
isAccessControlAllowed,
|
||||
},
|
||||
"SSO callback rejected: insufficient role management permissions"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
|
||||
const matchedLocale = await findMatchingLocale();
|
||||
|
||||
const userProfile = await prisma.$transaction(async (tx) => {
|
||||
const createdUser = await createUser(
|
||||
{
|
||||
name:
|
||||
userName ||
|
||||
user.email
|
||||
.split("@")[0]
|
||||
.replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ")
|
||||
.trim(),
|
||||
email: user.email,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
locale: matchedLocale,
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await syncSsoIdentityForUser({
|
||||
userId: createdUser.id,
|
||||
provider,
|
||||
account: {
|
||||
type: account.type,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
scope: account.scope,
|
||||
token_type: account.token_type,
|
||||
id_token: account.id_token,
|
||||
},
|
||||
tx,
|
||||
});
|
||||
|
||||
if (organization) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: createdUser.id, organizationId: organization.id, role: "member" },
|
||||
"Assigning user to organization"
|
||||
);
|
||||
await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx);
|
||||
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID },
|
||||
"Creating default team membership"
|
||||
);
|
||||
await createDefaultTeamMembership(createdUser.id, tx);
|
||||
}
|
||||
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...createdUser.notificationSettings,
|
||||
alert: {
|
||||
...createdUser.notificationSettings?.alert,
|
||||
},
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
|
||||
),
|
||||
};
|
||||
|
||||
await updateUser(
|
||||
createdUser.id,
|
||||
{
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
return createdUser;
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{ newUserId: userProfile.id, identityProvider: provider },
|
||||
"New SSO user created successfully"
|
||||
);
|
||||
|
||||
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
|
||||
|
||||
capturePostHogEvent(userProfile.id, "user_signed_up", {
|
||||
auth_provider: provider,
|
||||
email_domain: userProfile.email.split("@")[1],
|
||||
signup_source: callbackUrl?.includes("token=") ? "invite" : "direct",
|
||||
invite_organization_id: organization?.id ?? null,
|
||||
});
|
||||
|
||||
if (isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{ isMultiOrgEnabled, newUserId: userProfile.id },
|
||||
"Multi-org enabled, skipping organization assignment"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (organization) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleSsoCallback = async ({
|
||||
user,
|
||||
account,
|
||||
@@ -121,7 +466,7 @@ export const handleSsoCallback = async ({
|
||||
user: TUser;
|
||||
account: Account;
|
||||
callbackUrl: string;
|
||||
}) => {
|
||||
}): Promise<boolean | string> => {
|
||||
const contextLogger = logger.withContext({
|
||||
correlationId: crypto.randomUUID(),
|
||||
name: "formbricks",
|
||||
@@ -155,7 +500,11 @@ export const handleSsoCallback = async ({
|
||||
return false;
|
||||
}
|
||||
|
||||
let provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
const provider = normalizeSsoProvider(account.provider);
|
||||
if (!provider) {
|
||||
contextLogger.debug({ provider: account.provider }, "SSO callback rejected: unsupported provider");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider === "saml") {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
@@ -165,327 +514,82 @@ export const handleSsoCallback = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
contextLogger.debug(
|
||||
{ lookupType: "account_provider_account_id" },
|
||||
"Checking for existing linked user by provider account"
|
||||
);
|
||||
contextLogger.debug(
|
||||
{ lookupType: "account_provider_account_id" },
|
||||
"Checking for existing linked user by provider account"
|
||||
);
|
||||
const existingLinkedUser = await findLinkedSsoUser({
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
|
||||
const existingLinkedAccount = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: LINKED_SSO_LOOKUP_SELECT,
|
||||
},
|
||||
},
|
||||
if (existingLinkedUser) {
|
||||
return syncLinkedSsoUser({
|
||||
linkedUser: existingLinkedUser.linkedUser,
|
||||
user,
|
||||
account,
|
||||
provider,
|
||||
contextLogger,
|
||||
logSource: existingLinkedUser.logSource,
|
||||
legacyAccountIdToNormalize: existingLinkedUser.legacyAccountIdToNormalize,
|
||||
});
|
||||
}
|
||||
|
||||
if (existingLinkedAccount?.user) {
|
||||
return syncLinkedSsoUser({
|
||||
linkedUser: existingLinkedAccount.user,
|
||||
user,
|
||||
account,
|
||||
contextLogger,
|
||||
logSource: "account_row",
|
||||
});
|
||||
}
|
||||
contextLogger.debug(
|
||||
{ lookupType: "legacy_identity_provider_account_id" },
|
||||
"No account row found, checking for legacy linked SSO user"
|
||||
);
|
||||
const legacyExactMatch = await findLegacyExactMatch({
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{ lookupType: "legacy_identity_provider_account_id" },
|
||||
"No account row found, checking for legacy linked SSO user"
|
||||
);
|
||||
|
||||
const existingLegacyLinkedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
},
|
||||
select: LINKED_SSO_LOOKUP_SELECT,
|
||||
if (legacyExactMatch) {
|
||||
return syncLinkedSsoUser({
|
||||
linkedUser: legacyExactMatch,
|
||||
user,
|
||||
account,
|
||||
provider,
|
||||
contextLogger,
|
||||
logSource: "legacy_identity_provider",
|
||||
});
|
||||
}
|
||||
|
||||
if (existingLegacyLinkedUser) {
|
||||
return syncLinkedSsoUser({
|
||||
linkedUser: existingLegacyLinkedUser,
|
||||
user,
|
||||
account,
|
||||
contextLogger,
|
||||
logSource: "legacy_identity_provider",
|
||||
});
|
||||
}
|
||||
|
||||
// There is no existing linked account for this identity provider / account id
|
||||
// check if a user account with this email already exists and fail closed if so
|
||||
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
|
||||
|
||||
const existingUserWithEmail = await getUserByEmail(user.email);
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
existingUserId: existingUserWithEmail.id,
|
||||
existingIdentityProvider: existingUserWithEmail.identityProvider,
|
||||
},
|
||||
"SSO callback blocked: existing user found by email without linked provider account"
|
||||
);
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{ action: "new_user_creation" },
|
||||
"No existing user found, proceeding with new user creation"
|
||||
);
|
||||
|
||||
let userName = user.name;
|
||||
|
||||
if (provider === "openid") {
|
||||
const oidcUser = user as TUser & TOidcNameFields;
|
||||
if (oidcUser.name) {
|
||||
userName = oidcUser.name;
|
||||
} else if (oidcUser.given_name || oidcUser.family_name) {
|
||||
userName = `${oidcUser.given_name} ${oidcUser.family_name}`;
|
||||
} else if (oidcUser.preferred_username) {
|
||||
userName = oidcUser.preferred_username;
|
||||
}
|
||||
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!oidcUser.name,
|
||||
hasGivenName: !!oidcUser.given_name,
|
||||
hasFamilyName: !!oidcUser.family_name,
|
||||
hasPreferredUsername: !!oidcUser.preferred_username,
|
||||
},
|
||||
"Extracted OIDC user name"
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "saml") {
|
||||
const samlUser = user as TUser & TSamlNameFields;
|
||||
if (samlUser.name) {
|
||||
userName = samlUser.name;
|
||||
} else if (samlUser.firstName || samlUser.lastName) {
|
||||
userName = `${samlUser.firstName} ${samlUser.lastName}`;
|
||||
}
|
||||
contextLogger.debug(
|
||||
{
|
||||
hasName: !!samlUser.name,
|
||||
hasFirstName: !!samlUser.firstName,
|
||||
hasLastName: !!samlUser.lastName,
|
||||
},
|
||||
"Extracted SAML user name"
|
||||
);
|
||||
}
|
||||
|
||||
// Get multi-org license status
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const isFirstUser = await getIsFreshInstance();
|
||||
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
|
||||
const existingUserWithEmail = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
select: LINKED_SSO_LOOKUP_SELECT,
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
isMultiOrgEnabled,
|
||||
isFirstUser,
|
||||
skipInviteForSso: SKIP_INVITE_FOR_SSO,
|
||||
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
|
||||
existingUserId: existingUserWithEmail.id,
|
||||
existingIdentityProvider: existingUserWithEmail.identityProvider,
|
||||
},
|
||||
"License and instance configuration checked"
|
||||
"SSO callback requires inbox verification before linking"
|
||||
);
|
||||
|
||||
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
|
||||
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
|
||||
if (!callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{ reason: "missing_callback_url" },
|
||||
"SSO callback rejected: missing callback URL for invite validation"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse and validate the callback URL
|
||||
const isValidCallbackUrl = new URL(callbackUrl);
|
||||
// Extract invite token and source from URL parameters
|
||||
const inviteToken = isValidCallbackUrl.searchParams.get("token") || "";
|
||||
const source = isValidCallbackUrl.searchParams.get("source") || "";
|
||||
|
||||
// Allow sign-in if multi-org is enabled, otherwise check for invite token
|
||||
if (source === "signin" && !inviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "signin_without_invite_token" },
|
||||
"SSO callback rejected: signin without invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If multi-org is enabled, skip invite token validation
|
||||
// Verify invite token and check email match
|
||||
const { email, inviteId } = verifyInviteToken(inviteToken);
|
||||
if (email !== user.email) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invite_email_mismatch", inviteId },
|
||||
"SSO callback rejected: invite token email mismatch"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Check if invite token is still valid
|
||||
const isValidInviteToken = await getIsValidInviteToken(inviteId);
|
||||
if (!isValidInviteToken) {
|
||||
contextLogger.debug(
|
||||
{ reason: "invalid_invite_token", inviteId },
|
||||
"SSO callback rejected: invalid or expired invite token"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
contextLogger.debug({ inviteId }, "Invite token validation successful");
|
||||
} catch (err) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "invite_token_validation_error",
|
||||
error: err instanceof Error ? err.message : "unknown_error",
|
||||
},
|
||||
"SSO callback rejected: invite token validation failed"
|
||||
);
|
||||
// Log and reject on any validation errors
|
||||
contextLogger.error(err, "Invalid callbackUrl");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let organization: Organization | null = null;
|
||||
|
||||
if (!isFirstUser && !isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
|
||||
},
|
||||
"Determining organization assignment"
|
||||
);
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
|
||||
} else {
|
||||
organization = await getFirstOrganization();
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
contextLogger.debug(
|
||||
{ reason: "no_organization_found" },
|
||||
"SSO callback rejected: no organization found for assignment"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
if (!isAccessControlAllowed && !callbackUrl) {
|
||||
contextLogger.debug(
|
||||
{
|
||||
reason: "insufficient_role_permissions",
|
||||
organizationId: organization.id,
|
||||
isAccessControlAllowed,
|
||||
},
|
||||
"SSO callback rejected: insufficient role management permissions"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
|
||||
const matchedLocale = await findMatchingLocale();
|
||||
|
||||
const userProfile = await prisma.$transaction(async (tx) => {
|
||||
const createdUser = await createUser(
|
||||
{
|
||||
name:
|
||||
userName ||
|
||||
user.email
|
||||
.split("@")[0]
|
||||
.replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ")
|
||||
.trim(),
|
||||
email: user.email,
|
||||
emailVerified: new Date(Date.now()),
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
locale: matchedLocale,
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await syncSsoAccount(createdUser.id, account, tx);
|
||||
|
||||
if (organization) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: createdUser.id, organizationId: organization.id, role: "member" },
|
||||
"Assigning user to organization"
|
||||
);
|
||||
await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx);
|
||||
|
||||
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
|
||||
contextLogger.debug(
|
||||
{ newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID },
|
||||
"Creating default team membership"
|
||||
);
|
||||
await createDefaultTeamMembership(createdUser.id, tx);
|
||||
}
|
||||
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...createdUser.notificationSettings,
|
||||
alert: {
|
||||
...createdUser.notificationSettings?.alert,
|
||||
},
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([
|
||||
...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []),
|
||||
organization.id,
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
await updateUser(
|
||||
createdUser.id,
|
||||
{
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
return createdUser;
|
||||
return startSsoRecovery({
|
||||
existingUser: existingUserWithEmail,
|
||||
provider,
|
||||
account,
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
contextLogger.debug(
|
||||
{ newUserId: userProfile.id, identityProvider: provider },
|
||||
"New SSO user created successfully"
|
||||
);
|
||||
|
||||
// send new user to brevo
|
||||
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
|
||||
|
||||
capturePostHogEvent(userProfile.id, "user_signed_up", {
|
||||
auth_provider: provider,
|
||||
email_domain: userProfile.email.split("@")[1],
|
||||
signup_source: callbackUrl?.includes("token=") ? "invite" : "direct",
|
||||
invite_organization_id: organization?.id ?? null,
|
||||
});
|
||||
|
||||
if (isMultiOrgEnabled) {
|
||||
contextLogger.debug(
|
||||
{ isMultiOrgEnabled, newUserId: userProfile.id },
|
||||
"Multi-org enabled, skipping organization assignment"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default organization assignment if env variable is set
|
||||
if (organization) {
|
||||
return true;
|
||||
}
|
||||
// Without default organization assignment
|
||||
return true;
|
||||
}
|
||||
contextLogger.debug("SSO callback successful: default return");
|
||||
|
||||
return true;
|
||||
contextLogger.debug(
|
||||
{ action: "new_user_creation" },
|
||||
"No existing user found, proceeding with new user creation"
|
||||
);
|
||||
|
||||
return provisionNewSsoUser({
|
||||
user,
|
||||
account,
|
||||
provider,
|
||||
callbackUrl,
|
||||
contextLogger,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
|
||||
import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
import { syncSsoIdentityForUser } from "./account-linking";
|
||||
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl, startSsoRecovery } from "./sso-recovery";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createEmailToken: vi.fn(),
|
||||
createSsoRelinkIntent: vi.fn(),
|
||||
verifySsoRelinkIntent: vi.fn(),
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
createEmailToken: mocks.createEmailToken,
|
||||
createSsoRelinkIntent: mocks.createSsoRelinkIntent,
|
||||
verifySsoRelinkIntent: mocks.verifySsoRelinkIntent,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/sign-in-tracking", () => ({
|
||||
finalizeSuccessfulSignIn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/verification-links", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/auth/lib/verification-links")>();
|
||||
return {
|
||||
...actual,
|
||||
buildVerificationRequestedPath: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendVerificationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: mocks.queueAuditEventBackground,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./account-linking", () => ({
|
||||
LINKED_SSO_LOOKUP_SELECT: {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
},
|
||||
syncSsoIdentityForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("sso-recovery", () => {
|
||||
const txUserUpdate = vi.fn();
|
||||
const tx = {
|
||||
user: {
|
||||
update: txUserUpdate,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
async (callback: (tx: typeof tx) => Promise<unknown>) => await callback(tx)
|
||||
);
|
||||
vi.mocked(buildVerificationRequestedPath).mockReturnValue(
|
||||
"/auth/verification-requested?token=email-token&purpose=sso_recovery"
|
||||
);
|
||||
mocks.createEmailToken.mockReturnValue("email-token");
|
||||
mocks.createSsoRelinkIntent.mockReturnValue("intent-token");
|
||||
mocks.verifySsoRelinkIntent.mockReturnValue({
|
||||
userId: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
callbackUrl: "http://localhost:3000/environments/env_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves the recovery purpose when building the verification requested path", async () => {
|
||||
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
|
||||
|
||||
const result = await startSsoRecovery({
|
||||
existingUser: {
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: null,
|
||||
isActive: true,
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
},
|
||||
provider: "google",
|
||||
account: {
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
} as any,
|
||||
callbackUrl: "http://localhost:3000/environments/env_1",
|
||||
});
|
||||
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith({
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=intent-token",
|
||||
purpose: "sso_recovery",
|
||||
});
|
||||
expect(buildVerificationRequestedPath).toHaveBeenCalledWith({
|
||||
token: "email-token",
|
||||
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=intent-token",
|
||||
purpose: "sso_recovery",
|
||||
});
|
||||
expect(result).toBe("/auth/verification-requested?token=email-token&purpose=sso_recovery");
|
||||
});
|
||||
|
||||
test("records a failed recovery start when the verification email cannot be sent", async () => {
|
||||
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("smtp unavailable"));
|
||||
|
||||
await expect(
|
||||
startSsoRecovery({
|
||||
existingUser: {
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: null,
|
||||
isActive: true,
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
},
|
||||
provider: "google",
|
||||
account: {
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
} as any,
|
||||
callbackUrl: "https://evil.example/phish",
|
||||
})
|
||||
).rejects.toThrow("smtp unavailable");
|
||||
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "user_1",
|
||||
newObject: expect.objectContaining({
|
||||
callbackUrl: "http://localhost:3000",
|
||||
failureReason: "smtp unavailable",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("reclaims unverified local auth factors before linking SSO", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: null,
|
||||
isActive: true,
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: "encrypted-secret",
|
||||
backupCodes: "encrypted-codes",
|
||||
} as any);
|
||||
|
||||
const callbackUrl = await completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_1",
|
||||
});
|
||||
|
||||
expect(txUserUpdate).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user_1",
|
||||
},
|
||||
data: {
|
||||
backupCodes: null,
|
||||
emailVerified: expect.any(Date),
|
||||
password: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
expect(syncSsoIdentityForUser).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
provider: "google",
|
||||
account: {
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-account-1",
|
||||
},
|
||||
tx,
|
||||
});
|
||||
expect(finalizeSuccessfulSignIn).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
provider: "google",
|
||||
});
|
||||
expect(callbackUrl).toBe("http://localhost:3000/environments/env_1");
|
||||
});
|
||||
|
||||
test("does not clear local auth material for already verified users", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
|
||||
isActive: true,
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: "encrypted-secret",
|
||||
backupCodes: "encrypted-codes",
|
||||
} as any);
|
||||
|
||||
await completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_1",
|
||||
});
|
||||
|
||||
expect(txUserUpdate).not.toHaveBeenCalled();
|
||||
expect(syncSsoIdentityForUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("rejects recovery when the signed-in user does not match the intent owner", async () => {
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_2",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(syncSsoIdentityForUser).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "user_1",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "session_user_mismatch",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects recovery when there is no signed-in session", async () => {
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(syncSsoIdentityForUser).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "user_1",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "missing_session",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects recovery when the intent provider is invalid", async () => {
|
||||
mocks.verifySsoRelinkIntent.mockReturnValue({
|
||||
userId: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
provider: "unknown-provider",
|
||||
providerAccountId: "provider-account-1",
|
||||
callbackUrl: "http://localhost:3000/environments/env_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_1",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(syncSsoIdentityForUser).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "user_1",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "invalid_provider",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid or expired recovery intents before looking up any user", async () => {
|
||||
mocks.verifySsoRelinkIntent.mockImplementation(() => {
|
||||
throw new Error("expired");
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "expired-intent",
|
||||
sessionUserId: "user_1",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(syncSsoIdentityForUser).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "unknown",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "invalid_or_expired_intent",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects recovery when the verified user no longer matches the intended email", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "different@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
|
||||
isActive: true,
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "provider-account-1",
|
||||
password: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_1",
|
||||
})
|
||||
).rejects.toThrow("OAuthAccountNotLinked");
|
||||
|
||||
expect(syncSsoIdentityForUser).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: "user_1",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "user_mismatch",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("still completes recovery when sign-in finalization fails", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "john.doe@example.com",
|
||||
locale: "en-US",
|
||||
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
|
||||
isActive: true,
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "provider-account-1",
|
||||
password: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
} as any);
|
||||
vi.mocked(finalizeSuccessfulSignIn).mockRejectedValue(new Error("tracking unavailable"));
|
||||
|
||||
await expect(
|
||||
completeSsoRecovery({
|
||||
intentToken: "test-intent",
|
||||
sessionUserId: "user_1",
|
||||
})
|
||||
).resolves.toBe("http://localhost:3000/environments/env_1");
|
||||
|
||||
expect(syncSsoIdentityForUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("preserves only safe callback URLs in the failure redirect", () => {
|
||||
expect(getSsoRecoveryFailureRedirectUrl("http://localhost:3000/invite?token=invite-token")).toBe(
|
||||
"http://localhost:3000/auth/login?error=OAuthAccountNotLinked&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token"
|
||||
);
|
||||
expect(getSsoRecoveryFailureRedirectUrl("https://evil.example/phish")).toBe(
|
||||
"http://localhost:3000/auth/login?error=OAuthAccountNotLinked"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import type { IdentityProvider, Prisma } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { createEmailToken, createSsoRelinkIntent, verifySsoRelinkIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
|
||||
import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
import {
|
||||
LINKED_SSO_LOOKUP_SELECT,
|
||||
TSsoAccountLinkInput,
|
||||
TSsoLookupUser,
|
||||
syncSsoIdentityForUser,
|
||||
} from "./account-linking";
|
||||
import { OAUTH_ACCOUNT_NOT_LINKED_ERROR, SSO_RECOVERY_COMPLETION_PATH } from "./constants";
|
||||
import { normalizeSsoProvider } from "./provider-normalization";
|
||||
|
||||
const getSsoRecoveryLogger = (
|
||||
event: "sso_recovery_started" | "sso_recovery_completed" | "sso_recovery_failed"
|
||||
) =>
|
||||
logger.withContext({
|
||||
event,
|
||||
name: "formbricks",
|
||||
});
|
||||
|
||||
const queueSsoRecoveryAuditEvent = ({
|
||||
action,
|
||||
status,
|
||||
userId,
|
||||
email,
|
||||
provider,
|
||||
callbackUrl,
|
||||
failureReason,
|
||||
}: {
|
||||
action: "sso_recovery_started" | "sso_recovery_completed" | "sso_recovery_failed";
|
||||
status: "success" | "failure";
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
callbackUrl?: string;
|
||||
failureReason?: string;
|
||||
}) => {
|
||||
queueAuditEventBackground({
|
||||
action,
|
||||
targetType: "user",
|
||||
userId,
|
||||
targetId: userId,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status,
|
||||
userType: "user",
|
||||
newObject: {
|
||||
email,
|
||||
provider,
|
||||
...(callbackUrl ? { callbackUrl } : {}),
|
||||
...(failureReason ? { failureReason } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const SSO_RECOVERY_USER_SELECT = {
|
||||
...LINKED_SSO_LOOKUP_SELECT,
|
||||
backupCodes: true,
|
||||
password: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
} as const;
|
||||
|
||||
type TSsoRecoveryUser = Prisma.UserGetPayload<{
|
||||
select: typeof SSO_RECOVERY_USER_SELECT;
|
||||
}>;
|
||||
|
||||
const reclaimUnverifiedLocalAuthIfNeeded = async ({
|
||||
tx,
|
||||
user,
|
||||
}: {
|
||||
tx: Prisma.TransactionClient;
|
||||
user: TSsoRecoveryUser;
|
||||
}) => {
|
||||
if (user.identityProvider !== "email" || user.emailVerified) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inbox ownership is now proven, so strip any untrusted local auth factors before the SSO
|
||||
// account becomes the canonical way back in.
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: null,
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createSsoRecoveryCompletionUrl = (intentToken: string): string => {
|
||||
const completionUrl = new URL(SSO_RECOVERY_COMPLETION_PATH, WEBAPP_URL);
|
||||
completionUrl.searchParams.set("intent", intentToken);
|
||||
|
||||
return completionUrl.toString();
|
||||
};
|
||||
|
||||
export const getSsoRecoveryFailureRedirectUrl = (callbackUrl?: string): string => {
|
||||
const loginUrl = new URL("/auth/login", WEBAPP_URL);
|
||||
loginUrl.searchParams.set("error", OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
|
||||
if (validatedCallbackUrl) {
|
||||
loginUrl.searchParams.set("callbackUrl", validatedCallbackUrl);
|
||||
}
|
||||
|
||||
return loginUrl.toString();
|
||||
};
|
||||
|
||||
export const startSsoRecovery = async ({
|
||||
existingUser,
|
||||
provider,
|
||||
account,
|
||||
callbackUrl,
|
||||
}: {
|
||||
existingUser: TSsoLookupUser;
|
||||
provider: IdentityProvider;
|
||||
account: Account;
|
||||
callbackUrl: string;
|
||||
}): Promise<string> => {
|
||||
const originalCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL) ?? WEBAPP_URL;
|
||||
|
||||
try {
|
||||
const recoveryIntent = createSsoRelinkIntent({
|
||||
userId: existingUser.id,
|
||||
email: existingUser.email,
|
||||
provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
callbackUrl: originalCallbackUrl,
|
||||
});
|
||||
const completionUrl = createSsoRecoveryCompletionUrl(recoveryIntent);
|
||||
|
||||
await sendVerificationEmail({
|
||||
id: existingUser.id,
|
||||
email: existingUser.email,
|
||||
locale: existingUser.locale,
|
||||
callbackUrl: completionUrl,
|
||||
purpose: "sso_recovery",
|
||||
});
|
||||
|
||||
getSsoRecoveryLogger("sso_recovery_started").info(
|
||||
{
|
||||
userId: existingUser.id,
|
||||
provider,
|
||||
callbackUrl: originalCallbackUrl,
|
||||
},
|
||||
"SSO recovery started"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_started",
|
||||
status: "success",
|
||||
userId: existingUser.id,
|
||||
email: existingUser.email,
|
||||
provider,
|
||||
callbackUrl: originalCallbackUrl,
|
||||
});
|
||||
|
||||
return buildVerificationRequestedPath({
|
||||
token: createEmailToken(existingUser.email),
|
||||
callbackUrl: completionUrl,
|
||||
purpose: "sso_recovery",
|
||||
});
|
||||
} catch (error) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error(
|
||||
{
|
||||
error,
|
||||
userId: existingUser.id,
|
||||
provider,
|
||||
callbackUrl: originalCallbackUrl,
|
||||
},
|
||||
"Failed to start SSO recovery"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: existingUser.id,
|
||||
email: existingUser.email,
|
||||
provider,
|
||||
callbackUrl: originalCallbackUrl,
|
||||
failureReason: error instanceof Error ? error.message : "unknown_error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const completeSsoRecovery = async ({
|
||||
intentToken,
|
||||
sessionUserId,
|
||||
}: {
|
||||
intentToken: string;
|
||||
sessionUserId?: string;
|
||||
}): Promise<string> => {
|
||||
let intent: ReturnType<typeof verifySsoRelinkIntent>;
|
||||
|
||||
try {
|
||||
intent = verifySsoRelinkIntent(intentToken);
|
||||
} catch (error) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error({ error }, "Invalid or expired SSO recovery intent");
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: UNKNOWN_DATA,
|
||||
email: UNKNOWN_DATA,
|
||||
provider: "unknown",
|
||||
failureReason: "invalid_or_expired_intent",
|
||||
});
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
const provider = normalizeSsoProvider(intent.provider);
|
||||
|
||||
if (!provider) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error(
|
||||
{
|
||||
provider: intent.provider,
|
||||
},
|
||||
"SSO recovery failed due to an invalid provider"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: intent.userId,
|
||||
email: intent.email,
|
||||
provider: intent.provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
failureReason: "invalid_provider",
|
||||
});
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
if (!sessionUserId) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error(
|
||||
{
|
||||
userId: intent.userId,
|
||||
provider,
|
||||
},
|
||||
"SSO recovery failed because there is no signed-in session"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: intent.userId,
|
||||
email: intent.email,
|
||||
provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
failureReason: "missing_session",
|
||||
});
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
if (sessionUserId !== intent.userId) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error(
|
||||
{
|
||||
userId: intent.userId,
|
||||
provider,
|
||||
sessionUserId,
|
||||
},
|
||||
"SSO recovery failed because the signed-in user does not match the recovery intent"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: intent.userId,
|
||||
email: intent.email,
|
||||
provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
failureReason: "session_user_mismatch",
|
||||
});
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: intent.userId,
|
||||
},
|
||||
select: SSO_RECOVERY_USER_SELECT,
|
||||
});
|
||||
|
||||
if (user?.email !== intent.email) {
|
||||
getSsoRecoveryLogger("sso_recovery_failed").error(
|
||||
{
|
||||
userId: intent.userId,
|
||||
provider: intent.provider,
|
||||
},
|
||||
"SSO recovery failed due to user mismatch"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_failed",
|
||||
status: "failure",
|
||||
userId: intent.userId,
|
||||
email: intent.email,
|
||||
provider: intent.provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
failureReason: "user_mismatch",
|
||||
});
|
||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await reclaimUnverifiedLocalAuthIfNeeded({
|
||||
tx,
|
||||
user,
|
||||
});
|
||||
|
||||
const recoveryAccount: TSsoAccountLinkInput = {
|
||||
type: "oauth",
|
||||
provider,
|
||||
providerAccountId: intent.providerAccountId,
|
||||
};
|
||||
|
||||
await syncSsoIdentityForUser({
|
||||
userId: user.id,
|
||||
provider,
|
||||
account: recoveryAccount,
|
||||
tx,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await finalizeSuccessfulSignIn({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
provider,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to finalize sign-in after SSO recovery");
|
||||
}
|
||||
|
||||
getSsoRecoveryLogger("sso_recovery_completed").info(
|
||||
{
|
||||
userId: user.id,
|
||||
provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
},
|
||||
"SSO recovery completed"
|
||||
);
|
||||
queueSsoRecoveryAuditEvent({
|
||||
action: "sso_recovery_completed",
|
||||
status: "success",
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
provider,
|
||||
callbackUrl: intent.callbackUrl,
|
||||
});
|
||||
|
||||
return getValidatedCallbackUrl(intent.callbackUrl, WEBAPP_URL) ?? WEBAPP_URL;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,11 +38,17 @@ import {
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
} from "@/lib/jwt";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getElementResponseMapping } from "@/lib/responses";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { buildVerificationLinks } from "@/modules/auth/lib/verification-links";
|
||||
import { TVerificationRequestPurpose, buildVerificationLinks } from "@/modules/auth/lib/verification-links";
|
||||
import { resolveStorageUrl } from "@/modules/storage/utils";
|
||||
|
||||
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
|
||||
@@ -128,21 +134,26 @@ export const sendVerificationEmail = async ({
|
||||
email,
|
||||
locale,
|
||||
callbackUrl,
|
||||
purpose = "email_verification",
|
||||
}: {
|
||||
id: string;
|
||||
email: TUserEmail;
|
||||
locale: TUserLocale;
|
||||
callbackUrl?: string;
|
||||
purpose?: TVerificationRequestPurpose;
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const t = await getTranslate(locale);
|
||||
const token = createToken(id, {
|
||||
expiresIn: "1d",
|
||||
purpose,
|
||||
});
|
||||
const { verifyLink, verificationRequestLink } = buildVerificationLinks({
|
||||
token,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
callbackUrl,
|
||||
purpose,
|
||||
verificationRequestToken: createEmailToken(email),
|
||||
});
|
||||
|
||||
const html = await renderVerificationEmail({
|
||||
|
||||
@@ -331,7 +331,13 @@ export const getEnvironmentLayoutData = reactCache(
|
||||
user,
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organization: {
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
stripe: organization.billing.stripe ?? undefined,
|
||||
},
|
||||
},
|
||||
environments,
|
||||
membership,
|
||||
isAccessControlAllowed,
|
||||
|
||||
@@ -68,7 +68,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updatedProject: Project["logo"] = {
|
||||
const updatedProject = {
|
||||
logo: { url: logoUrl, bgColor: isBgColorEnabled ? logoBgColor : undefined },
|
||||
};
|
||||
const updateProjectResponse = await updateProjectAction({
|
||||
@@ -98,7 +98,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updatedProject: Project["logo"] = {
|
||||
const updatedProject = {
|
||||
logo: { url: undefined, bgColor: undefined },
|
||||
};
|
||||
const updateProjectResponse = await updateProjectAction({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { debounce } from "lodash";
|
||||
import { ImagePlusIcon, TrashIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { debounce } from "@/lib/utils/debounce";
|
||||
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
|
||||
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RecallWrapper } from "@/modules/survey/components/element-form-input/components/recall-wrapper";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
@@ -63,8 +64,8 @@ export const createSurvey = async (
|
||||
}
|
||||
|
||||
// Validate and prepare blocks
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debounce } from "lodash";
|
||||
import { useState } from "react";
|
||||
import { debounce } from "@/lib/utils/debounce";
|
||||
|
||||
interface AnimatedSurveyBgProps {
|
||||
handleBgChange: (bg: string, bgType: string) => void;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Project } from "@prisma/client";
|
||||
import { isEqual } from "lodash";
|
||||
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
ZSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { isDeepEqual } from "@/lib/utils/object";
|
||||
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
import { TSurveyDraft } from "@/modules/survey/editor/types/survey";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
@@ -117,7 +117,7 @@ export const SurveyMenuBar = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEqual(localSurvey, survey)) {
|
||||
if (!isDeepEqual(localSurvey, survey)) {
|
||||
e.preventDefault();
|
||||
return (e.returnValue = warningText);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export const SurveyMenuBar = ({
|
||||
const { updatedAt, ...localSurveyRest } = localSurvey;
|
||||
const { updatedAt: _, ...surveyRest } = survey;
|
||||
|
||||
if (!isEqual(localSurveyRest, surveyRest)) {
|
||||
if (!isDeepEqual(localSurveyRest, surveyRest)) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
router.back();
|
||||
@@ -279,7 +279,7 @@ export const SurveyMenuBar = ({
|
||||
const { updatedAt: surveyUpdatedAt, ...surveyRest } = surveyRef.current;
|
||||
|
||||
// Skip if no changes
|
||||
if (isEqual(localSurveyRest, surveyRest)) return;
|
||||
if (isDeepEqual(localSurveyRest, surveyRest)) return;
|
||||
|
||||
isAutoSavingRef.current = true;
|
||||
|
||||
@@ -296,7 +296,7 @@ export const SurveyMenuBar = ({
|
||||
// If the segment changed on the server (e.g., private segment was deleted when
|
||||
// switching from app to link type), update localSurvey to prevent stale segment
|
||||
// references when publishing
|
||||
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
|
||||
if (!isDeepEqual(localSurveyRef.current.segment, savedData.segment)) {
|
||||
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user