Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent e85933c64e fix: duplicate PR #7752 Hungarian translation
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 09:09:43 +00:00
222 changed files with 6192 additions and 14536 deletions
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@v3
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v3
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 --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+48 -37
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,48 +85,65 @@ 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=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Start RustFS Server
- 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
run: |
set -euo pipefail
# Start RustFS server in background
# Start MinIO server in background
docker run -d \
--name rustfs-server \
--name minio-server \
-p 9000:9000 \
-p 9001: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
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "RustFS server started"
echo "MinIO server started"
- name: Bootstrap RustFS bucket and browser upload CORS
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
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
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
- name: Build App
run: |
@@ -225,14 +242,8 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
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
run: cat app.log
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -3
View File
@@ -2,7 +2,6 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -40,7 +39,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -50,7 +49,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@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.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@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",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
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 PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
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 PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -6,9 +6,11 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique } from "./user";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
@@ -1,5 +1,42 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -3,22 +3,25 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { TSurvey } from "@formbricks/types/surveys/types";
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 = ({ activeId }: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const navigation = [
{
@@ -28,7 +31,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environment.id, survey.id);
revalidateSurveyIdPath(environmentId, survey.id);
},
},
{
@@ -38,7 +41,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environment.id, survey.id);
revalidateSurveyIdPath(environmentId, survey.id);
},
},
];
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import React, { createContext, useCallback, useContext, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date;
to?: Date | undefined;
}
interface FilterDateContextProps {
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
const refreshAnalysisData = useCallback(async () => {
await refreshHandlerRef.current?.();
}, []);
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
refreshHandlerRef.current = handler;
return () => {
if (refreshHandlerRef.current === handler) {
refreshHandlerRef.current = null;
}
};
}, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
);
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -2,8 +2,6 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -15,7 +13,6 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
@@ -49,8 +46,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -89,34 +86,6 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
}, [survey]);
@@ -165,8 +134,6 @@ export const ResponsePage = ({
}
};
fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
@@ -1,4 +1,5 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -8,7 +9,6 @@ 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) {
@@ -64,6 +64,8 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -74,7 +76,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation activeId="responses" />
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -4,13 +4,16 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Confetti } from "@/modules/ui/components/confetti";
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -71,7 +71,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -111,7 +111,7 @@ export const SummaryPage = ({
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays]);
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -131,39 +131,13 @@ export const SummaryPage = ({
}
}, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -171,11 +145,21 @@ export const SummaryPage = ({
return;
}
const fetchFilteredSummary = async () => {
const fetchSummary = async () => {
setIsLoading(true);
try {
await fetchSummary();
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
@@ -183,8 +167,8 @@ export const SummaryPage = ({
}
};
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,18 @@
"use client";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
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 { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-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,6 +23,8 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -39,6 +41,8 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -59,12 +63,9 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -76,7 +77,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(globalThis.location.search);
const params = new URLSearchParams(window.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -146,25 +147,6 @@ export const SurveyAnalysisCTA = ({
};
const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -201,7 +183,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 />
<SurveyStatusDropdown environment={environment} survey={survey} />
)}
<IconBar actions={iconActions} />
@@ -233,7 +215,7 @@ export const SurveyAnalysisCTA = ({
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage />
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
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}"
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
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>
@@ -54,15 +48,6 @@ 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>
</>
);
};
@@ -66,6 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -76,7 +78,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation activeId="summary" />
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
@@ -3,9 +3,8 @@
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 {
@@ -17,9 +16,17 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
export const SurveyStatusDropdown = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -39,6 +46,10 @@ export const SurveyStatusDropdown = () => {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,9 @@ 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 {
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{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">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
<span className="cursor-pointer 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);
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
<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(), locale)}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
))}
</div>
@@ -1,5 +1,4 @@
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";
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
airtableArray = await getAirtableTables(params.environmentId);
}
if (isReadOnly) {
return redirect("./");
@@ -58,7 +51,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -91,15 +91,10 @@ export const POST = async (request: Request) => {
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging.
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
// Fetch with timeout of 5 seconds to prevent hanging
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, { ...options, redirect: redirectMode }),
fetch(url, options),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -185,20 +185,4 @@ 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,12 +26,6 @@ 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;
@@ -123,10 +117,6 @@ 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) {
@@ -1,67 +0,0 @@
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,8 +10,6 @@ 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";
@@ -129,114 +127,6 @@ 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, getIntegrationByType } from "@/lib/integration/service";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -78,16 +78,12 @@ 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: existingData,
data: [],
email,
},
};
@@ -1,7 +1,8 @@
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 { getAirtableToken, getTables } from "@/lib/airtable/service";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -35,7 +36,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = await getIntegrationByType(environmentId, "airtable");
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
if (!integration) {
return {
@@ -43,12 +44,7 @@ export const GET = withV1ApiWrapper({
};
}
// 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
);
const tables = await getTables(integration.config.key, baseId.data);
return {
response: responses.successResponse(tables),
};
@@ -4,7 +4,6 @@ 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";
@@ -177,7 +176,6 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -68,20 +68,11 @@ describe("parseV3SurveysListQuery", () => {
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.includeTotalCount).toBe(true);
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("parses includeTotalCount=false", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.includeTotalCount).toBe(false);
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
@@ -111,7 +102,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -28,7 +28,6 @@ const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
"includeTotalCount",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
@@ -54,11 +53,6 @@ const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
includeTotalCount: z
.enum(["true", "false"])
.optional()
.transform((value) => value !== "false")
.default(true),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
@@ -77,7 +71,6 @@ export type TV3SurveysListQueryParseResult =
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
includeTotalCount: boolean;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
@@ -118,7 +111,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
@@ -161,7 +153,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
includeTotalCount: q.includeTotalCount,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
+1 -24
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
@@ -257,29 +257,6 @@ describe("GET /api/v3/surveys", () => {
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("skips totalCount when includeTotalCount=false", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: null,
});
const cursor = encodeSurveyListPageCursor({
version: 1,
sortBy: "updatedAt",
value: "2026-04-15T10:00:00.000Z",
id: "survey_1",
});
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
expect(getSurveyCount).not.toHaveBeenCalled();
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
+11 -12
View File
@@ -46,22 +46,21 @@ export const GET = withV3ApiWrapper({
const { environmentId } = authResult;
const surveyPagePromise = getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
});
const totalCountPromise = parsed.includeTotalCount
? getSurveyCount(environmentId, parsed.filterCriteria)
: Promise.resolve(null);
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveyPage.surveys.map(serializeV3SurveyListItem),
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor: surveyPage.nextCursor,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
-1
View File
@@ -19,7 +19,6 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+13 -6
View File
@@ -170,7 +170,6 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -316,6 +315,7 @@ checksums:
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -333,6 +333,7 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -347,7 +348,6 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -468,6 +468,7 @@ checksums:
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
@@ -640,6 +641,8 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
@@ -789,9 +792,6 @@ 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
@@ -1137,7 +1137,7 @@ checksums:
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
@@ -1239,7 +1239,12 @@ checksums:
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
environments/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
environments/surveys/copy_survey_description: 66d0aadf192ad5790fbf3f55f3bb5485
environments/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
environments/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
environments/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
environments/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
environments/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
@@ -1960,6 +1965,7 @@ checksums:
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
environments/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
environments/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -2044,6 +2050,7 @@ checksums:
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
+5 -11
View File
@@ -3,6 +3,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -23,11 +24,6 @@ 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);
};
@@ -39,11 +35,6 @@ 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;
@@ -87,7 +78,10 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
-1
View File
@@ -182,7 +182,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
-7
View File
@@ -213,13 +213,6 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+3 -11
View File
@@ -5,12 +5,7 @@ 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,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -99,10 +94,7 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -114,7 +106,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
return integration ? transformIntegration(integration) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
-78
View File
@@ -6,13 +6,11 @@ import {
createEmailChangeToken,
createEmailToken,
createInviteToken,
createSsoRelinkIntent,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
@@ -382,7 +380,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -417,7 +414,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -429,7 +425,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -1009,78 +1004,5 @@ 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();
});
});
});
});
+5 -108
View File
@@ -1,4 +1,4 @@
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
import jwt, { JwtPayload } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
@@ -13,39 +13,7 @@ const decryptWithFallback = (encryptedText: string, key: string): 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 => {
export const createToken = (userId: string, options = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -55,9 +23,7 @@ export const createToken = (userId: string, options: TVerificationTokenOptions =
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
@@ -258,72 +224,7 @@ const getUserEmailForLegacyVerification = async (
return { userId: decryptedId, userEmail: foundUser.email };
};
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> => {
export const verifyToken = async (token: string): Promise<JwtPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -362,11 +263,7 @@ export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> =
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return {
id: userData.userId,
email: userData.userEmail,
purpose: getVerificationTokenPurpose(payload.purpose),
};
return { id: userData.userId, email: userData.userEmail };
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
+6 -2
View File
@@ -1,4 +1,8 @@
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -25,7 +29,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = await getIntegrationByType(environmentId, "notion");
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+1 -1
View File
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
};
};
+4 -1
View File
@@ -1,3 +1,6 @@
const structuredCloneExport = globalThis.structuredClone;
import structuredClonePolyfill from "@ungap/structured-clone";
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
export { structuredCloneExport as structuredClone };
-64
View File
@@ -1,64 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
posthog: {
__loaded: false,
getFeatureFlag: vi.fn(),
},
}));
vi.mock("posthog-js", () => ({
default: mocks.posthog,
}));
describe("getPostHogClientFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.posthog.__loaded = false;
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
});
test("returns false before PostHog is initialized", async () => {
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
});
test("returns true from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(true);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
});
test("returns false from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(false);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
test("returns variant string from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue("variant-a");
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(undefined);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
});
-15
View File
@@ -1,15 +0,0 @@
"use client";
import posthog from "posthog-js";
import type { TPostHogFeatureFlagValue } from "./types";
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
if (!posthog.__loaded) {
return false;
}
const featureFlagValue = posthog.getFeatureFlag(flagKey);
return featureFlagValue ?? false;
};
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
@@ -1,131 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
loggerWarn: vi.fn(),
}));
describe("getPostHogFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test("returns false when PostHog is not configured", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("returns false when posthogServerClient is null", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: null,
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
mocks.getFeatureFlag.mockResolvedValue(true);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(
getPostHogFeatureFlag("user123", "experiment-flag", {
organizationId: "org_123",
workspaceId: "ws_456",
})
).resolves.toBe(true);
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
groups: {
organization: "org_123",
workspace: "ws_456",
},
});
});
test("preserves variant string responses", async () => {
mocks.getFeatureFlag.mockResolvedValue("variant-a");
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.getFeatureFlag.mockResolvedValue(undefined);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
});
test("logs and returns false when PostHog throws", async () => {
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), flagKey: "experiment-flag" },
"Failed to evaluate PostHog feature flag"
);
});
});
-35
View File
@@ -1,35 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { POSTHOG_KEY } from "@/lib/constants";
import { posthogServerClient } from "./server";
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
const groups = {
...(context?.organizationId ? { organization: context.organizationId } : {}),
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
};
return Object.keys(groups).length > 0 ? groups : undefined;
};
export const getPostHogFeatureFlag = async (
distinctId: string,
flagKey: string,
context?: TPostHogFeatureFlagContext
): Promise<TPostHogFeatureFlagValue> => {
if (!POSTHOG_KEY || !posthogServerClient) {
return false;
}
try {
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
groups: buildPostHogGroups(context),
});
return featureFlagValue ?? false;
} catch (error) {
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
return false;
}
};
-4
View File
@@ -1,5 +1 @@
import "server-only";
export { capturePostHogEvent } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
-6
View File
@@ -1,6 +0,0 @@
export type TPostHogFeatureFlagValue = boolean | string;
export type TPostHogFeatureFlagContext = {
organizationId?: string;
workspaceId?: string;
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlack, 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");
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+7 -11
View File
@@ -190,14 +190,6 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
showResponseCount: false,
};
const mockBlocks = [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
];
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
@@ -209,7 +201,13 @@ const baseSurveyProperties = {
displayLimit: 3,
welcomeCard: mockWelcomeCard,
questions: [],
blocks: mockBlocks as unknown as SurveyMock["blocks"],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
@@ -306,7 +304,6 @@ export const createSurveyInput: TSurveyCreateInput = {
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
...baseSurveyProperties,
blocks: mockBlocks,
};
export const updateSurveyInput: TSurvey = {
@@ -329,7 +326,6 @@ export const updateSurveyInput: TSurvey = {
followUps: [],
...baseSurveyProperties,
...commonMockProperties,
blocks: mockBlocks,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
+34 -6
View File
@@ -5,8 +5,7 @@ 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 { ZSegmentFilters } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
@@ -559,7 +558,22 @@ export const updateSurveyInternal = async (
select: selectSurvey,
});
return transformPrismaSurvey<TSurvey>(prismaSurvey);
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;
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -634,8 +648,8 @@ export const createSurvey = async (
}
// Validate and prepare blocks for persistence
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
@@ -759,7 +773,21 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
}
return transformPrismaSurvey<TSurvey>(prismaSurvey);
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;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+1 -2
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,7 +17,6 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
-36
View File
@@ -1,36 +0,0 @@
import "server-only";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
const getUserAuthenticationData = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
return await verifyPassword(password, user.password);
};
-20
View File
@@ -1,20 +0,0 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,7 +6,6 @@ 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", () => ({
@@ -48,6 +47,11 @@ 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[] = [
@@ -98,12 +102,8 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: publicUserSelect,
select: expect.any(Object),
});
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: publicUserSelect,
select: expect.any(Object),
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: publicUserSelect,
select: expect.any(Object),
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: publicUserSelect,
select: expect.any(Object),
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: publicUserSelect,
select: expect.any(Object),
});
});
+21 -7
View File
@@ -10,7 +10,21 @@ 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";
import { publicUserSelect } from "./public-user";
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,
};
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -21,7 +35,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: publicUserSelect,
select: responseSelection,
});
if (!user) {
@@ -45,7 +59,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: publicUserSelect,
select: responseSelection,
});
return user;
@@ -68,7 +82,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: publicUserSelect,
select: responseSelection,
});
return updatedUser;
@@ -91,7 +105,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: publicUserSelect,
select: responseSelection,
});
return user;
} catch (error) {
@@ -139,7 +153,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: publicUserSelect,
select: responseSelection,
});
return users;
@@ -160,7 +174,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: publicUserSelect,
select: responseSelection,
});
if (!user) {
-27
View File
@@ -1,27 +0,0 @@
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;
};
-35
View File
@@ -1,35 +0,0 @@
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])
);
};
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -374,7 +373,6 @@
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"refresh": "Aktualisieren",
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
"create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
"organization_name": "Organisationsname",
"organization_name_description": "Gib deiner Organisation einen Namen.",
"organization_name_placeholder": "z. B. Acme Inc.",
"organization_name_placeholder": "z. B. Powerpuff Girls",
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
"organization_settings": "Organisationseinstellungen",
"please_add_a_logo": "Bitte füge ein Logo hinzu",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
"update_personal_info": "Persönliche Daten aktualisieren",
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
"wrong_password": "Falsches Passwort"
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
},
"teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"data_refreshed_successfully": "Data refreshed successfully",
"date": "Date",
"days": "days",
"default": "Default",
@@ -374,7 +373,6 @@
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read docs",
"recipients": "Recipients",
"refresh": "Refresh",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
"create_new_attribute": "Create new attribute",
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Your organization invite link is ready!",
"organization_name": "Organization Name",
"organization_name_description": "Give your organization a descriptive name.",
"organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_placeholder": "e.g. Power Puff Girls",
"organization_name_updated_successfully": "Organization name updated successfully",
"organization_settings": "Organization Settings",
"please_add_a_logo": "Please add a logo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
"update_personal_info": "Update your personal information",
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
"warning_cannot_undo": "This cannot be undone",
"wrong_password": "Wrong password"
"warning_cannot_undo": "This cannot be undone"
},
"teams": {
"add_members_description": "Add members to the team and determine their role.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"data_refreshed_successfully": "Datos actualizados correctamente",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
@@ -374,7 +373,6 @@
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"refresh": "Actualizar",
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
"create_new_attribute": "Crear atributo nuevo",
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
"organization_name": "Nombre de la organización",
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
"organization_name_placeholder": "p. ej. Acme Inc.",
"organization_name_placeholder": "p. ej. Power Puff Girls",
"organization_name_updated_successfully": "Nombre de la organización actualizado correctamente",
"organization_settings": "Configuración de la organización",
"please_add_a_logo": "Por favor, añade un logotipo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
"update_personal_info": "Actualiza tu información personal",
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
"warning_cannot_undo": "Esto no se puede deshacer",
"wrong_password": "Contraseña incorrecta"
"warning_cannot_undo": "Esto no se puede deshacer"
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"data_refreshed_successfully": "Données actualisées avec succès",
"date": "Date",
"days": "jours",
"default": "Par défaut",
@@ -374,7 +373,6 @@
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire la documentation",
"recipients": "Destinataires",
"refresh": "Actualiser",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
"create_new_attribute": "Créer un nouvel attribut",
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
"organization_name": "Nom de l'organisation",
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
"organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_placeholder": "e.g. Power Puff Girls",
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
"organization_settings": "Paramètres de l'organisation",
"please_add_a_logo": "Veuillez ajouter un logo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Cette opération est irréversible.",
"wrong_password": "Mot de passe incorrect"
"warning_cannot_undo": "Cette opération est irréversible."
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"data_refreshed_successfully": "Az adatok sikeresen frissítve lettek",
"date": "Dátum",
"days": "nap",
"default": "Alapértelmezett",
@@ -374,7 +373,6 @@
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
"read_docs": "Dokumentáció elolvasása",
"recipients": "Címzettek",
"refresh": "Frissítés",
"remove": "Eltávolítás",
"remove_from_team": "Eltávolítás a csapatból",
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
"create_new_attribute": "Új attribútum létrehozása",
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
"organization_name": "Szervezet neve",
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
"organization_name_placeholder": "például Acme Inc.",
"organization_name_placeholder": "például Pindúr pandúrok",
"organization_name_updated_successfully": "A szervezet neve sikeresen frissítve",
"organization_settings": "Szervezet beállításai",
"please_add_a_logo": "Adjon hozzá egy logót",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
"update_personal_info": "Személyes információk frissítése",
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
"warning_cannot_undo": "Ezt nem lehet visszavonni",
"wrong_password": "Hibás jelszó"
"warning_cannot_undo": "Ezt nem lehet visszavonni"
},
"teams": {
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"data_refreshed_successfully": "データが正常に更新されました",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -374,7 +373,6 @@
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"refresh": "更新",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
"create_new_attribute": "新しい属性を作成",
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
@@ -833,9 +833,6 @@
},
"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": "このチャンネルには別のフォームがすでに接続されています。",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
"organization_name": "組織名",
"organization_name_description": "組織に分かりやすい名前を付けます。",
"organization_name_placeholder": "例: Acme Inc.",
"organization_name_placeholder": "例: パワーパフガールズ",
"organization_name_updated_successfully": "組織名を正常に更新しました",
"organization_settings": "組織設定",
"please_add_a_logo": "ロゴを追加してください",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
"update_personal_info": "個人情報を更新",
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
"warning_cannot_undo": "この操作は元に戻せません",
"wrong_password": "パスワードが間違っています"
"warning_cannot_undo": "この操作は元に戻せません"
},
"teams": {
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"data_refreshed_successfully": "Gegevens succesvol vernieuwd",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -374,7 +373,6 @@
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
"read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"refresh": "Vernieuwen",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"contacts_table_refresh": "Vernieuw contacten",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
"create_new_attribute": "Nieuw attribuut aanmaken",
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
"organization_name": "Organisatienaam",
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
"organization_name_placeholder": "bijv. Acme Inc.",
"organization_name_placeholder": "bijv. Power Puff-meisjes",
"organization_name_updated_successfully": "Organisatienaam is succesvol bijgewerkt",
"organization_settings": "Organisatie-instellingen",
"please_add_a_logo": "Voeg een logo toe",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
"update_personal_info": "Update uw persoonlijke gegevens",
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
"wrong_password": "Verkeerd wachtwoord"
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
},
"teams": {
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"data_refreshed_successfully": "Dados atualizados com sucesso",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
@@ -374,7 +373,6 @@
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"refresh": "Atualizar",
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contato excluído com sucesso",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê um nome descritivo pra sua organização.",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"organization_name_placeholder": "por exemplo, Meninas Superpoderosas",
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
"organization_settings": "Configurações da Organização",
"please_add_a_logo": "Por favor, adicione um logo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
"update_personal_info": "Atualize suas informações pessoais",
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
"warning_cannot_undo": "Isso não pode ser desfeito",
"wrong_password": "Senha incorreta"
"warning_cannot_undo": "Isso não pode ser desfeito"
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"data_refreshed_successfully": "Dados atualizados com sucesso",
"date": "Data",
"days": "dias",
"default": "Padrão",
@@ -374,7 +373,6 @@
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"refresh": "Atualizar",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê à sua organização um nome descritivo.",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"organization_name_placeholder": "por exemplo, Power Puff Girls",
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
"organization_settings": "Configurações da organização",
"please_add_a_logo": "Por favor, adicione um logótipo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
"update_personal_info": "Atualize as suas informações pessoais",
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
"warning_cannot_undo": "Isto não pode ser desfeito",
"wrong_password": "Palavra-passe incorreta"
"warning_cannot_undo": "Isto não pode ser desfeito"
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"data_refreshed_successfully": "Datele au fost actualizate cu succes",
"date": "Dată",
"days": "zile",
"default": "Implicit",
@@ -374,7 +373,6 @@
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"refresh": "Actualizează",
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
"contact_deleted_successfully": "Contact șters cu succes",
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
"create_new_attribute": "Creează atribut nou",
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
"organization_name": "Nume Organizație",
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
"organization_name_placeholder": "ex. Acme Inc.",
"organization_name_placeholder": "ex. Power Puff Girls",
"organization_name_updated_successfully": "Numele organizației actualizat cu succes",
"organization_settings": "Setări Organizație",
"please_add_a_logo": "Adaugă un logo",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
"warning_cannot_undo": "Aceasta nu poate fi anulată",
"wrong_password": "Parolă greșită"
"warning_cannot_undo": "Aceasta nu poate fi anulată"
},
"teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"data_refreshed_successfully": "Данные успешно обновлены",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -374,7 +373,6 @@
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
"read_docs": "Читать документацию",
"recipients": "Получатели",
"refresh": "Обновить",
"remove": "Удалить",
"remove_from_team": "Удалить из команды",
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"contact_deleted_successfully": "Контакт успешно удалён",
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
"create_new_attribute": "Создать новый атрибут",
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
@@ -833,9 +833,6 @@
},
"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": "Вы уже подключили другой опрос к этому каналу.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
"organization_name": "Название организации",
"organization_name_description": "Дайте вашей организации понятное название.",
"organization_name_placeholder": "например, Acme Inc.",
"organization_name_placeholder": "например, Power Puff Girls",
"organization_name_updated_successfully": "Название организации успешно обновлено",
"organization_settings": "Настройки организации",
"please_add_a_logo": "Пожалуйста, добавьте логотип",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
"update_personal_info": "Обновить личную информацию",
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
"warning_cannot_undo": "Это действие необратимо",
"wrong_password": "Неверный пароль"
"warning_cannot_undo": "Это действие необратимо"
},
"teams": {
"add_members_description": "Добавьте участников в команду и определите их роль.",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"data_refreshed_successfully": "Data uppdaterades",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -374,7 +373,6 @@
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
"read_docs": "Läs dokumentation",
"recipients": "Mottagare",
"refresh": "Uppdatera",
"remove": "Ta bort",
"remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"contact_deleted_successfully": "Kontakt borttagen",
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
"create_new_attribute": "Skapa nytt attribut",
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
@@ -833,9 +833,6 @@
},
"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.",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
"organization_name": "Organisationsnamn",
"organization_name_description": "Ge din organisation ett beskrivande namn.",
"organization_name_placeholder": "t.ex. Acme Inc.",
"organization_name_placeholder": "t.ex. Power Puff Girls",
"organization_name_updated_successfully": "Organisationsnamn uppdaterat",
"organization_settings": "Organisationsinställningar",
"please_add_a_logo": "Vänligen lägg till en logotyp",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
"update_personal_info": "Uppdatera din personliga information",
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
"warning_cannot_undo": "Detta kan inte ångras",
"wrong_password": "Fel lösenord"
"warning_cannot_undo": "Detta kan inte ångras"
},
"teams": {
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"data_refreshed_successfully": "数据刷新成功",
"date": "日期",
"days": "天",
"default": "默认",
@@ -374,7 +373,6 @@
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读文档",
"recipients": "收件人",
"refresh": "刷新",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
"contact_deleted_successfully": "联系人 删除 成功",
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"create_attribute": "创建属性",
"create_new_attribute": "创建新属性",
"create_new_attribute_description": "为细分目的创建新属性。",
@@ -833,9 +833,6 @@
},
"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": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
"organization_name": "组织 名称",
"organization_name_description": "为您的组织 提供一个描述性的名称",
"organization_name_placeholder": "例如 Acme Inc.",
"organization_name_placeholder": "例如 Power Puff Girls",
"organization_name_updated_successfully": "组织 名称 更新 成功",
"organization_settings": "组织 设置",
"please_add_a_logo": "请添加 徽标",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
"update_personal_info": "更新你的个人信息",
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
"warning_cannot_undo": "此 无法 撤销。",
"wrong_password": "密码错误"
"warning_cannot_undo": "此 无法 撤销。"
},
"teams": {
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
+4 -8
View File
@@ -197,7 +197,6 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"data_refreshed_successfully": "資料已成功重新整理",
"date": "日期",
"days": "天",
"default": "預設",
@@ -374,7 +373,6 @@
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
"read_docs": "閱讀文件",
"recipients": "收件者",
"refresh": "重新整理",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
@@ -676,6 +674,8 @@
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
"contact_deleted_successfully": "聯絡人已成功刪除",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"create_attribute": "建立屬性",
"create_new_attribute": "建立新屬性",
"create_new_attribute_description": "建立新屬性以進行分群用途。",
@@ -833,9 +833,6 @@
},
"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": "您已將另一個問卷連線到此頻道。",
@@ -1198,7 +1195,7 @@
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
"organization_name": "組織名稱",
"organization_name_description": "為您的組織提供描述性名稱。",
"organization_name_placeholder": "例如:Acme Inc.",
"organization_name_placeholder": "例如:飛天小女警",
"organization_name_updated_successfully": "組織名稱已成功更新",
"organization_settings": "組織設定",
"please_add_a_logo": "請新增標誌",
@@ -1256,8 +1253,7 @@
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原",
"wrong_password": "密碼錯誤"
"warning_cannot_undo": "此操作無法復原"
},
"teams": {
"add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1,89 +1,24 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
"Password and email confirmation are required to delete your account.";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().min(1).max(255),
password: z.string().max(128).optional(),
})
.strict();
const parseDeleteUserConfirmation = (input: unknown) => {
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return password;
};
const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
if (isPasswordBackedAccount) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
await deleteUser(ctx.user.id);
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
throw error;
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging("deleted", "user", async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
})
);
@@ -1 +0,0 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -3,16 +3,12 @@
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
interface DeleteAccountModalProps {
open: boolean;
@@ -32,57 +28,15 @@ export const DeleteAccountModal = ({
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const isPasswordBackedAccount = user.identityProvider === "email";
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setInputValue("");
setPassword("");
}
setOpen(nextOpen);
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
return;
}
setDeleting(true);
const result = await deleteUserAction(
isPasswordBackedAccount
? {
confirmationEmail: inputValue,
password,
}
: {
confirmationEmail: inputValue,
}
);
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = fallbackErrorMessage;
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
} else if (result) {
errorMessage = getFormattedErrorMessage(result);
}
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
await deleteUserAction();
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
@@ -98,22 +52,22 @@ export const DeleteAccountModal = ({
window.location.replace("/auth/login");
}
} catch (error) {
logger.error({ error }, "Account deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
toast.error("Something went wrong");
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={handleOpenChange}
setOpen={setOpen}
deleteWhat={t("common.account")}
onDelete={() => deleteAccount()}
text={t("environments.settings.profile.account_deletion_consequences_warning")}
isDeleting={deleting}
disabled={isDeleteDisabled}>
disabled={inputValue !== user.email}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
@@ -156,29 +110,11 @@ export const DeleteAccountModal = ({
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}
className="mt-2"
className="mt-5"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{isPasswordBackedAccount && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
</label>
<PasswordInput
data-testid="deleteAccountPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
containerClassName="mt-2"
id="deleteAccountPassword"
name="deleteAccountPassword"
required
/>
</>
)}
</form>
</div>
</DeleteDialog>
+3 -3
View File
@@ -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: unknown[] | undefined | null,
blocks: TSurveyBlock[] | 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 (Array.isArray(blocks) && blocks.length > 0) {
blocksToUse = blocks as TSurveyBlock[];
if (blocks && blocks.length > 0) {
blocksToUse = blocks;
} else if (questions && questions.length > 0) {
// Transform legacy questions format to blocks for validation
blocksToUse = transformQuestionsToBlocks(questions, []);
+10 -18
View File
@@ -1,32 +1,22 @@
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: unknown[],
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined => {
// Check if this is an "other" option (not in predefined choices)
const matchingChoice = choices.find(
(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
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
);
// If this is an "other" option with value that's too long, reject the response
@@ -41,7 +31,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
responseLanguage,
}: {
responseData?: TResponseData;
surveyQuestions: TQuestionWithOtherOptionValidation[];
surveyQuestions: TSurveyElement[];
responseLanguage?: string;
}): string | undefined => {
if (!responseData) return undefined;
@@ -49,9 +39,11 @@ export const validateOtherOptionLengthForMultipleChoice = ({
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;
const isMultiChoice = question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle";
const isMultiChoice =
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
if (!isMultiChoice || !question.choices) continue;
if (!isMultiChoice) continue;
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
if (error) return error;
@@ -62,7 +54,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
function validateAnswer(
answer: unknown,
choices: unknown[],
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined {
@@ -13,10 +13,6 @@ 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" }] } }],
@@ -64,10 +60,6 @@ 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");
}
});
@@ -92,10 +84,6 @@ 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");
}
});
@@ -160,10 +148,6 @@ 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,6 +1,5 @@
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";
@@ -36,22 +35,3 @@ 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,11 +3,8 @@ 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";
@@ -136,10 +133,6 @@ 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);
@@ -322,105 +315,6 @@ 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 });
@@ -538,51 +432,6 @@ 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);
}
});
});
});
@@ -640,57 +489,6 @@ 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)", () => {
+41 -46
View File
@@ -10,15 +10,16 @@ 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 { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -266,13 +267,12 @@ export const authOptions: NextAuthOptions = {
throw new Error("Token not found");
}
const { id, purpose } = await verifyToken(credentials?.token);
const foundUser = await prisma.user.findUnique({
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
where: {
id: id,
},
});
user = foundUser ? { ...foundUser, authFlowPurpose: purpose } : null;
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
@@ -291,10 +291,7 @@ export const authOptions: NextAuthOptions = {
throw new Error("Either a user does not match the provided token or the token is invalid");
}
const authFlowPurpose = user.authFlowPurpose ?? "email_verification";
const isSsoRecovery = authFlowPurpose === "sso_recovery";
if (user.emailVerified && !isSsoRecovery) {
if (user.emailVerified) {
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
throw new Error("Email already verified");
}
@@ -304,20 +301,14 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
if (!user.emailVerified && !isSsoRecovery) {
const updatedUser = await updateUser(user.id, { emailVerified: new Date() });
user = {
...updatedUser,
authFlowPurpose,
};
user = await updateUser(user.id, { emailVerified: new Date() });
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;
},
@@ -348,27 +339,37 @@ export const authOptions: NextAuthOptions = {
const userEmail = user.email ?? "";
const userId = user.id as string;
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
// 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");
}
};
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
@@ -378,20 +379,14 @@ export const authOptions: NextAuthOptions = {
callbackUrl,
});
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
if (result) {
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
}
return result;
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account?.provider ?? "unknown",
});
void captureSignIn(account?.provider ?? "unknown");
await updateUserLastLoginAt(userEmail);
return true;
},
},
+13 -2
View File
@@ -1,5 +1,9 @@
import { prisma } from "@formbricks/database";
import { getSessionTokenFromCookieStore } from "./session-cookie";
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;
@@ -10,7 +14,14 @@ type TRequestWithCookies = {
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
return getSessionTokenFromCookieStore(request.cookies);
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
@@ -1,49 +0,0 @@
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;
};
@@ -1,101 +0,0 @@
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,
});
});
});
@@ -1,57 +0,0 @@
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 });
};
+10 -28
View File
@@ -17,7 +17,6 @@ const mockPrismaUser = {
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
user: {
create: vi.fn(),
update: vi.fn(),
@@ -30,14 +29,6 @@ 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", () => {
@@ -93,31 +84,22 @@ describe("User Management", () => {
});
describe("updateUserLastLoginAt", () => {
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 mockUpdateData = { name: "Updated Name" };
test("updates a user successfully", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name });
const result = await updateUserLastLoginAt(mockUser.email);
expect(result).toEqual(previousLastLoginAt);
expect(result).toEqual(void 0);
});
test("throws ResourceNotFoundError when user doesn't exist", async () => {
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([]),
user: {
update: vi.fn(),
},
} as any)
);
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError);
});
+7 -27
View File
@@ -44,35 +44,15 @@ export const updateUserLastLoginAt = async (email: string) => {
validateInputs([email, ZUserEmail]);
try {
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;
await prisma.user.update({
where: {
email,
},
data: {
lastLoginAt: new Date(),
},
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
@@ -21,18 +21,6 @@ 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({
@@ -60,21 +48,4 @@ 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,18 +1,13 @@
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);
@@ -21,10 +16,6 @@ export const buildVerificationRequestedPath = ({
verificationRequestedUrl.searchParams.set("callbackUrl", callbackUrl);
}
if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) {
verificationRequestedUrl.searchParams.set("purpose", purpose);
}
return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`;
};
@@ -32,31 +23,23 @@ 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", verificationRequestToken);
verificationRequestLink.searchParams.set("token", token);
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,6 +1,5 @@
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";
@@ -30,18 +29,10 @@ vi.mock("@/modules/email", () => ({
sendVerificationEmail: vi.fn(),
}));
vi.mock("@/lib/jwt", () => ({
verifySsoRelinkIntent: vi.fn(),
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
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),
}));
@@ -80,9 +71,6 @@ describe("resendVerificationEmailAction", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(verifySsoRelinkIntent).mockImplementation(() => {
throw new Error("invalid");
});
});
afterEach(() => {
@@ -162,7 +150,6 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
purpose: "email_verification",
});
expect(result).toEqual({ success: true });
});
@@ -182,7 +169,6 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: "http://localhost:3000/invite?token=invite-token",
purpose: "email_verification",
});
});
@@ -201,7 +187,6 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
purpose: "email_verification",
});
});
@@ -220,86 +205,6 @@ 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);

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