mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 914a165d6a | |||
| 7fba2247fb | |||
| 4128731c5f | |||
| ef96426ca0 | |||
| ce1dbe8b00 | |||
| 444f043140 | |||
| 2d32c0d671 | |||
| 8dc70a5e30 | |||
| 3e4e55fbf1 | |||
| fcfedd6e15 | |||
| 6c4342690f | |||
| b8c361fcf3 | |||
| 8771a0ec91 |
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
};
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Dein Link ist abgelaufen.",
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
|
||||
"link_expired_heading": "Dein Link ist abgelaufen."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Akzeptiert",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
|
||||
"verify_email_before_submission_button": "Überprüfen",
|
||||
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
|
||||
"want_to_respond": "Möchtest Du antworten?"
|
||||
"want_to_respond": "Möchtest Du antworten?",
|
||||
"paused_heading": "Pausiert",
|
||||
"completed_heading": "Abgeschlossen"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Your link is expired.",
|
||||
"link_expired_description": "The link you used is no longer valid."
|
||||
"link_expired_description": "The link you used is no longer valid.",
|
||||
"link_expired_heading": "Your link is expired."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepted",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verify your email to respond",
|
||||
"verify_email_before_submission_button": "Verify",
|
||||
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
|
||||
"want_to_respond": "Want to respond?"
|
||||
"want_to_respond": "Want to respond?",
|
||||
"paused_heading": "Paused",
|
||||
"completed_heading": "Completed"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Tu enlace ha caducado.",
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido."
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido.",
|
||||
"link_expired_heading": "Tu enlace ha caducado."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceptado",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
|
||||
"want_to_respond": "¿Quieres responder?"
|
||||
"want_to_respond": "¿Quieres responder?",
|
||||
"paused_heading": "Pausado",
|
||||
"completed_heading": "Completado"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Votre lien est expiré.",
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
|
||||
"link_expired_heading": "Votre lien est expiré."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepté",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Vérifiez votre email pour répondre.",
|
||||
"verify_email_before_submission_button": "Vérifier",
|
||||
"verify_email_before_submission_description": "Pour répondre à cette enquête, veuillez vérifier votre e-mail.",
|
||||
"want_to_respond": "Voulez-vous répondre ?"
|
||||
"want_to_respond": "Voulez-vous répondre ?",
|
||||
"paused_heading": "En pause",
|
||||
"completed_heading": "Terminé"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "A hivatkozása lejárt.",
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
|
||||
"link_expired_heading": "A hivatkozása lejárt."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Elfogadva",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Ellenőrizze az e-mail-címét a válaszadáshoz",
|
||||
"verify_email_before_submission_button": "Ellenőrzés",
|
||||
"verify_email_before_submission_description": "A kérdőívre való válaszadáshoz ellenőrizze az e-mail-címét",
|
||||
"want_to_respond": "Szeretne válaszolni?"
|
||||
"want_to_respond": "Szeretne válaszolni?",
|
||||
"paused_heading": "Szüneteltetve",
|
||||
"completed_heading": "Befejezve"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "リンクの有効期限が切れています。",
|
||||
"link_expired_description": "使用したリンクはすでに無効です。"
|
||||
"link_expired_description": "使用したリンクはすでに無効です。",
|
||||
"link_expired_heading": "リンクの有効期限が切れています。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "承認済み",
|
||||
@@ -833,6 +834,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": "このチャンネルには別のフォームがすでに接続されています。",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "回答するにはメールアドレスを認証してください",
|
||||
"verify_email_before_submission_button": "認証",
|
||||
"verify_email_before_submission_description": "このフォームに回答するには、メールアドレスを認証してください",
|
||||
"want_to_respond": "回答しますか?"
|
||||
"want_to_respond": "回答しますか?",
|
||||
"paused_heading": "一時停止",
|
||||
"completed_heading": "完了"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Uw link is verlopen.",
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig."
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
|
||||
"link_expired_heading": "Uw link is verlopen."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Geaccepteerd",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verifieer uw e-mailadres om te reageren",
|
||||
"verify_email_before_submission_button": "Verifiëren",
|
||||
"verify_email_before_submission_description": "Om op deze enquête te reageren, dient u uw e-mailadres te verifiëren",
|
||||
"want_to_respond": "Wilt u reageren?"
|
||||
"want_to_respond": "Wilt u reageren?",
|
||||
"paused_heading": "Gepauzeerd",
|
||||
"completed_heading": "Voltooid"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Seu link está expirado.",
|
||||
"link_expired_description": "O link que você usou não é mais válido."
|
||||
"link_expired_description": "O link que você usou não é mais válido.",
|
||||
"link_expired_heading": "Seu link está expirado."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceito",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verifique seu e-mail para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a esta pesquisa, confirme seu e-mail",
|
||||
"want_to_respond": "Quer responder?"
|
||||
"want_to_respond": "Quer responder?",
|
||||
"paused_heading": "Pausado",
|
||||
"completed_heading": "Concluído"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "O seu link expirou.",
|
||||
"link_expired_description": "O link que utilizou já não é válido."
|
||||
"link_expired_description": "O link que utilizou já não é válido.",
|
||||
"link_expired_heading": "O seu link expirou."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceite",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verifique o seu email para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a este questionário, por favor verifique o seu email",
|
||||
"want_to_respond": "Quer responder?"
|
||||
"want_to_respond": "Quer responder?",
|
||||
"paused_heading": "Em pausa",
|
||||
"completed_heading": "Concluído"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Link-ul dumneavoastră a expirat.",
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
|
||||
"link_expired_heading": "Link-ul dumneavoastră a expirat."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Acceptat",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verificați-vă emailul pentru a răspunde",
|
||||
"verify_email_before_submission_button": "Verifică",
|
||||
"verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul",
|
||||
"want_to_respond": "Dorești să răspunzi?"
|
||||
"want_to_respond": "Dorești să răspunzi?",
|
||||
"paused_heading": "Pauză",
|
||||
"completed_heading": "Completat"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Ваша ссылка истекла.",
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
|
||||
"link_expired_heading": "Ваша ссылка истекла."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Принято",
|
||||
@@ -833,6 +834,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": "Вы уже подключили другой опрос к этому каналу.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Подтвердите свой email, чтобы ответить",
|
||||
"verify_email_before_submission_button": "Подтвердить",
|
||||
"verify_email_before_submission_description": "Чтобы ответить на этот опрос, пожалуйста, подтвердите свой email",
|
||||
"want_to_respond": "Хотите ответить?"
|
||||
"want_to_respond": "Хотите ответить?",
|
||||
"paused_heading": "Приостановлено",
|
||||
"completed_heading": "Завершено"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Din länk har gått ut.",
|
||||
"link_expired_description": "Länken du använde är inte längre giltig."
|
||||
"link_expired_description": "Länken du använde är inte längre giltig.",
|
||||
"link_expired_heading": "Din länk har gått ut."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepterad",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Verifiera din e-post för att svara",
|
||||
"verify_email_before_submission_button": "Verifiera",
|
||||
"verify_email_before_submission_description": "För att svara på denna enkät, vänligen verifiera din e-post",
|
||||
"want_to_respond": "Vill du svara?"
|
||||
"want_to_respond": "Vill du svara?",
|
||||
"paused_heading": "Pausad",
|
||||
"completed_heading": "Slutförd"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Bağlantınızın süresi doldu.",
|
||||
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil."
|
||||
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil.",
|
||||
"link_expired_heading": "Bağlantınızın süresi doldu."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Kabul Edildi",
|
||||
@@ -833,6 +834,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.",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "Yanıtlamak için email adresinizi doğrulayın",
|
||||
"verify_email_before_submission_button": "Doğrula",
|
||||
"verify_email_before_submission_description": "Bu survey'e yanıt vermek için lütfen email adresinizi doğrulayın",
|
||||
"want_to_respond": "Yanıtlamak ister misiniz?"
|
||||
"want_to_respond": "Yanıtlamak ister misiniz?",
|
||||
"paused_heading": "Duraklatıldı",
|
||||
"completed_heading": "Tamamlandı"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您的 链接 已过期。",
|
||||
"link_expired_description": "您 使用 的 链接 已失效。"
|
||||
"link_expired_description": "您 使用 的 链接 已失效。",
|
||||
"link_expired_heading": "您的 链接 已过期。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -833,6 +834,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": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "验证 您的 邮件 以 响应",
|
||||
"verify_email_before_submission_button": "验证",
|
||||
"verify_email_before_submission_description": "要 响应 此 调查,请 验证 您的 邮件",
|
||||
"want_to_respond": "想要 参与 吗?"
|
||||
"want_to_respond": "想要 参与 吗?",
|
||||
"paused_heading": "暂停",
|
||||
"completed_heading": "完成"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您 的 連結 已過期。",
|
||||
"link_expired_description": "您 使用 的 連結 已無效。"
|
||||
"link_expired_description": "您 使用 的 連結 已無效。",
|
||||
"link_expired_heading": "您 的 連結 已過期。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -833,6 +834,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": "您已將另一個問卷連線到此頻道。",
|
||||
@@ -2442,7 +2446,9 @@
|
||||
"verify_email_before_submission": "驗證您的電子郵件以回應",
|
||||
"verify_email_before_submission_button": "驗證",
|
||||
"verify_email_before_submission_description": "若要回應此問卷,請驗證您的電子郵件",
|
||||
"want_to_respond": "想要回應嗎?"
|
||||
"want_to_respond": "想要回應嗎?",
|
||||
"paused_heading": "已暫停",
|
||||
"completed_heading": "已完成"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { debounce } from "lodash";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import UnsplashImage from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyBackgroundBgType } from "@formbricks/types/surveys/types";
|
||||
import { debounce } from "@/lib/utils/debounce";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user