diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper.tsx index 7d5f0069a411..a23492303972 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper.tsx @@ -41,13 +41,19 @@ export const GoogleSheetWrapper = ({ (TIntegrationGoogleSheetsConfigData & { index: number }) | null >(null); + // Depend only on stable values (workspaceId is a primitive; isConnected is local state). The + // server-derived `googleSheetIntegration` object must NOT be a dependency: every authenticated + // server action re-sets the Better Auth session cookie, which refreshes the route and hands this + // component a new `googleSheetIntegration` reference — depending on it would re-fire the action in + // an infinite loop. `isConnected` already implies an integration exists, and the action only needs + // `workspaceId`, so the object isn't needed here. const validateConnection = useCallback(async () => { - if (!isConnected || !googleSheetIntegration) return; + if (!isConnected) return; const response = await validateGoogleSheetsConnectionAction({ workspaceId }); if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) { setShowReconnectButton(true); } - }, [workspaceId, isConnected, googleSheetIntegration]); + }, [workspaceId, isConnected]); useEffect(() => { validateConnection(); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index 7bd3d327eefd..3dff6a560bb7 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -158,18 +158,41 @@ export const SummaryPage = ({ return registerAnalysisRefreshHandler(refreshSummary); }, [refreshSummary, registerAnalysisRefreshHandler]); + // Dedupe fetches by the actual filter VALUES. Every authenticated server action re-sets the + // Better Auth session cookie, and `cookies().set()` inside an action makes Next.js refresh the + // route; that refresh hands this component fresh `survey`/`initialSurveySummary`/`fetchSummary` + // references. Without this guard those new references re-fire the fetch effect → another action → + // another refresh, i.e. an infinite loop. + const lastFetchedFiltersKeyRef = useRef(null); + // 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) && - (!dateRange || (!dateRange.from && !dateRange.to)); + // A default date range only sets `to` (today) with no `from`, which means "all time" and is + // NOT an active filter. Treat filters as active only when the user has actually narrowed the data. + const hasActiveFilters = + (selectedFilter?.filter?.length ?? 0) > 0 || + (!!selectedFilter?.responseStatus && selectedFilter.responseStatus !== "all") || + Boolean(dateRange?.from); - if (initialSurveySummary && hasNoFilters) { + // If we have server-provided initial data and no active filters, use it instead of refetching. + // (Also covers clearing filters: fall back to the unfiltered initial summary without a fetch.) + if (initialSurveySummary && !hasActiveFilters) { + lastFetchedFiltersKeyRef.current = null; + setSurveySummary(initialSurveySummary); setIsLoading(false); return; } + // Skip when only prop references changed (a route refresh) but the filter values are identical + // to the last fetch. getFormattedFilters is deterministic for fixed inputs. + const filtersKey = JSON.stringify(getFormattedFilters(survey, selectedFilter, dateRange)); + if (filtersKey === lastFetchedFiltersKeyRef.current) { + return; + } + // Commit the key BEFORE awaiting so an action-triggered route refresh that re-runs this effect + // finds it already set and skips — this is what breaks the loop. + lastFetchedFiltersKeyRef.current = filtersKey; + const fetchFilteredSummary = async () => { setIsLoading(true); @@ -177,13 +200,17 @@ export const SummaryPage = ({ await fetchSummary(); } catch (error) { console.error(error); + // Roll back the key on failure so re-selecting the same filter retries the fetch. + lastFetchedFiltersKeyRef.current = null; + // fetchSummary throws an Error whose message is already the formatted server error. + toast.error(error instanceof Error ? error.message : t("common.something_went_wrong")); } finally { setIsLoading(false); } }; fetchFilteredSummary(); - }, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]); + }, [selectedFilter, dateRange, initialSurveySummary, fetchSummary, survey, t]); const surveyMemoized = useMemo(() => { return replaceHeadlineRecall(survey, "default"); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx index 687bd8a673af..b78e5008fac8 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -2,7 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { TI18nString } from "@formbricks/types/i18n"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; @@ -86,29 +86,36 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption; }; + // Keep the latest survey in a ref so the effect below can read it without listing `survey` (a + // server-derived object) as a dependency. Every authenticated server action re-sets the Better Auth + // session cookie → Next.js route refresh → new `survey` reference; depending on it here would re-fire + // getSurveyFilterDataAction on every refresh while the popover is open, i.e. an infinite loop. + const surveyRef = useRef(survey); + surveyRef.current = survey; + useEffect(() => { + if (!isOpen) return; // Fetch the initial data for the filter and load it into the state const handleInitialData = async () => { - if (isOpen) { - const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id }); + const survey = surveyRef.current; + const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id }); - if (!surveyFilterData?.data) return; + if (!surveyFilterData?.data) return; - const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data; - const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions( - survey, - environmentTags, - attributes, - meta, - hiddenFields, - quotas - ); - setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions }); - } + const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data; + const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions( + survey, + environmentTags, + attributes, + meta, + hiddenFields, + quotas + ); + setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions }); }; handleInitialData(); - }, [isOpen, setSelectedOptions, survey]); + }, [isOpen, setSelectedOptions]); const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => { const matchingFilterOption = selectedOptions.elementFilterOptions.find( diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 7a2f2174546f..5c3598dee853 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2906,11 +2906,6 @@ checksums: workspace/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8 workspace/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987 workspace/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80 - workspace/surveys/edit/element_category_input: c281c28cbb062bc3538cbd4a42d79cf6 - workspace/surveys/edit/element_category_choice: 307101bb385166b538e9370428963461 - workspace/surveys/edit/element_category_scoring: 29dbcaf797b1e9f985bbd7b5258749e4 - workspace/surveys/edit/element_category_contact: 9afa39bc47019ee6dec6c74b6273967c - workspace/surveys/edit/element_category_content: 669b337031ef165af577f04f2b7fd54f workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117 workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2 workspace/surveys/edit/clear_close_on_date: 673ed6940f36b02cf871ffacf034e114 @@ -2962,6 +2957,11 @@ checksums: workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54 workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318 + workspace/surveys/edit/element_category_choice: 307101bb385166b538e9370428963461 + workspace/surveys/edit/element_category_contact: 9afa39bc47019ee6dec6c74b6273967c + workspace/surveys/edit/element_category_content: 669b337031ef165af577f04f2b7fd54f + workspace/surveys/edit/element_category_input: c281c28cbb062bc3538cbd4a42d79cf6 + workspace/surveys/edit/element_category_scoring: 29dbcaf797b1e9f985bbd7b5258749e4 workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1 workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428 workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13 diff --git a/apps/web/modules/analysis/components/RatingSmiley/index.tsx b/apps/web/modules/analysis/components/RatingSmiley/index.tsx index 34d9a9f959cc..69cbb3b78133 100644 --- a/apps/web/modules/analysis/components/RatingSmiley/index.tsx +++ b/apps/web/modules/analysis/components/RatingSmiley/index.tsx @@ -82,19 +82,19 @@ const getSmiley = ({ ); return ( - - {" "} - {/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */} - - - + + + + +
- {icon} -
+ {icon} +
); }; diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 0e3a309651a5..7a3af61a2805 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -155,8 +155,12 @@ export function useChartDialog({ return () => { cancelled = true; }; + // Key on initialChart?.id, NOT the object reference. Every authenticated action here + // (getChartAction / executeQueryAction) re-sets the Better Auth session cookie → Next.js route + // refresh → new `initialChart` reference; depending on the object would re-fire executeQueryAction on + // every refresh → infinite loop. The id is the stable identity; content is unchanged across refreshes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, chartId, workspaceId, initialChart]); + }, [open, chartId, workspaceId, initialChart?.id]); const handleChartGenerated = (data: AnalyticsResponse) => { setChartData(data); diff --git a/apps/web/modules/ee/unify-feedback/components/feedback-record-form-drawer.tsx b/apps/web/modules/ee/unify-feedback/components/feedback-record-form-drawer.tsx index ea8e337097f8..67335537b537 100644 --- a/apps/web/modules/ee/unify-feedback/components/feedback-record-form-drawer.tsx +++ b/apps/web/modules/ee/unify-feedback/components/feedback-record-form-drawer.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { FingerprintIcon, LanguagesIcon, PlusIcon, SparklesIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -154,11 +154,18 @@ export const FeedbackRecordFormDrawer = ({ setCustomSourceType(""); }, [directories, form]); + // resetForCreate depends on `directories`, which is derived from a server prop (frdMap) that gets a + // fresh reference on every route refresh. Every authenticated action here (retrieveFeedbackRecordAction) + // re-sets the Better Auth session cookie → route refresh → new resetForCreate ref → this effect would + // re-fire the action → infinite loop. Read it through a ref so it isn't an effect trigger. + const resetForCreateRef = useRef(resetForCreate); + resetForCreateRef.current = resetForCreate; + useEffect(() => { if (!open) return; if (mode === "create") { - resetForCreate(); + resetForCreateRef.current(); return; } @@ -184,7 +191,7 @@ export const FeedbackRecordFormDrawer = ({ }; void loadRecord(); - }, [form, mode, onOpenChange, open, recordId, resetForCreate, t, workspaceId]); + }, [form, mode, onOpenChange, open, recordId, t, workspaceId]); const requestClose = useCallback(() => { if (form.formState.isDirty && !isSubmitting) { diff --git a/apps/web/proxy.test.ts b/apps/web/proxy.test.ts index 381ee3fbea02..b1891f8fcec8 100644 --- a/apps/web/proxy.test.ts +++ b/apps/web/proxy.test.ts @@ -125,4 +125,26 @@ describe("proxy", () => { expect(response.cookies.get("formbricks-workspace-id")).toBeUndefined(); }); + + test("does not re-set the active-workspace cookie when the request already carries the same value", async () => { + mockGetProxySession.mockResolvedValue(null); + + const request = new NextRequest("http://localhost:3000/workspaces/ws-123/surveys"); + request.cookies.set("formbricks-workspace-id", "ws-123"); + + const response = await proxy(request); + + expect(response.cookies.get("formbricks-workspace-id")).toBeUndefined(); + }); + + test("updates the active-workspace cookie when navigating to a different workspace", async () => { + mockGetProxySession.mockResolvedValue(null); + + const request = new NextRequest("http://localhost:3000/workspaces/ws-456/surveys"); + request.cookies.set("formbricks-workspace-id", "ws-123"); + + const response = await proxy(request); + + expect(response.cookies.get("formbricks-workspace-id")?.value).toBe("ws-456"); + }); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 8b306b4dbc69..8f79ae8db649 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -84,8 +84,14 @@ export const proxy = async (originalRequest: NextRequest) => { // Remember the active workspace so the workspace-agnostic org-settings shell can resolve it // server-side (localStorage is browser-only). Mirrors the /workspaces/[workspaceId] path segment. + // Only set the cookie when the value actually changes: a Set-Cookie on a server-action POST makes + // Next.js treat the action as revalidated, forcing a router refresh after every action — on pages + // that call an action on mount this becomes an infinite POST/refresh loop. const workspaceMatch = /^\/workspaces\/([^/]+)/.exec(request.nextUrl.pathname); - if (workspaceMatch?.[1]) { + if ( + workspaceMatch?.[1] && + request.cookies.get(FORMBRICKS_WORKSPACE_ID_COOKIE)?.value !== workspaceMatch[1] + ) { nextResponseWithCustomHeader.cookies.set(FORMBRICKS_WORKSPACE_ID_COOKIE, workspaceMatch[1], { path: "/", sameSite: "lax", diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 1d2b7a5ec859..2df1cabe8c1d 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -382,62 +382,64 @@ async function main(): Promise { // Users const passwordHash = await hashPassword(SEED_CREDENTIALS.ADMIN.password, 10); - await prisma.user.upsert({ - where: { id: SEED_IDS.USER_ADMIN }, - update: {}, - create: { + const seedUsers = [ + { id: SEED_IDS.USER_ADMIN, name: "Admin User", email: SEED_CREDENTIALS.ADMIN.email, - password: passwordHash, - emailVerified: true, - memberships: { - create: { - organizationId: organization.id, - role: "owner", - accepted: true, - }, - }, + role: "owner", }, - }); - - await prisma.user.upsert({ - where: { id: SEED_IDS.USER_MANAGER }, - update: {}, - create: { + { id: SEED_IDS.USER_MANAGER, name: "Manager User", email: SEED_CREDENTIALS.MANAGER.email, - password: passwordHash, - emailVerified: true, - memberships: { - create: { - organizationId: organization.id, - role: "manager", - accepted: true, - }, - }, + role: "manager", }, - }); - - await prisma.user.upsert({ - where: { id: SEED_IDS.USER_MEMBER }, - update: {}, - create: { + { id: SEED_IDS.USER_MEMBER, name: "Member User", email: SEED_CREDENTIALS.MEMBER.email, - password: passwordHash, - emailVerified: true, - memberships: { - create: { - organizationId: organization.id, - role: "member", - accepted: true, + role: "member", + }, + ] as const; + + for (const { id, name, email, role } of seedUsers) { + await prisma.user.upsert({ + where: { id }, + update: {}, + create: { + id, + name, + email, + password: passwordHash, + emailVerified: true, + memberships: { + create: { + organizationId: organization.id, + role, + accepted: true, + }, }, }, - }, - }); + }); + + // Better Auth verifies email/password sign-in against a "credential" Account row + // (providerAccountId = user id), not User.password — same shape as the ENG-1054 + // credential-account backfill migration. + await prisma.account.upsert({ + where: { + provider_providerAccountId: { provider: "credential", providerAccountId: id }, + }, + update: { password: passwordHash }, + create: { + userId: id, + type: "credential", + provider: "credential", + providerAccountId: id, + password: passwordHash, + }, + }); + } // Workspace const workspace = await prisma.workspace.upsert({