Repository: formbricks/formbricks (2025-07-23T08:50:26Z)

Issue:
Multiple unauthorised error on survey list page for users with read access and single use surveys
### Issue Summary

![Image](https://github.com/user-attachments/assets/61a67771-c40a-44ac-af0d-607cdd8c97a7)

It happens because it tries to create single use links as members which throws an unauthorised error

### Expected Behavior

No error toast

### Other information (incl. screenshots, Formbricks version, steps to reproduce,...)

**Steps to reproduce**

1. Create a single use survey 
2. Login in to an account with only read access to a particular product
3. Open survey list page

### Your Environment

None


Discussion:

Patch:
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
index e1b4a8cc3a8e..e9b5c9d35793 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx
@@ -65,7 +65,7 @@ export const SurveyAnalysisCTA = ({
   const [isResetting, setIsResetting] = useState(false);
 
   const { organizationId, project } = useEnvironment();
-  const { refreshSingleUseId } = useSingleUseId(survey);
+  const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
 
   const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
 
@@ -212,6 +212,7 @@ export const SurveyAnalysisCTA = ({
           segments={segments}
           isContactsEnabled={isContactsEnabled}
           isFormbricksCloud={isFormbricksCloud}
+          isReadOnly={isReadOnly}
         />
       )}
       <SuccessMessage environment={environment} survey={survey} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx
index 495829d353fe..3038ec05dd5c 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx
@@ -34,6 +34,7 @@ interface ShareSurveyModalProps {
   segments: TSegment[];
   isContactsEnabled: boolean;
   isFormbricksCloud: boolean;
+  isReadOnly: boolean;
 }
 
 export const ShareSurveyModal = ({
@@ -46,6 +47,7 @@ export const ShareSurveyModal = ({
   segments,
   isContactsEnabled,
   isFormbricksCloud,
+  isReadOnly,
 }: ShareSurveyModalProps) => {
   const environmentId = survey.environmentId;
   const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -75,6 +77,7 @@ export const ShareSurveyModal = ({
           setSurveyUrl,
           locale: user.locale,
           surveyUrl,
+          isReadOnly,
         },
       },
       {
@@ -192,6 +195,7 @@ export const ShareSurveyModal = ({
           tabs={linkTabs}
           handleViewChange={handleViewChange}
           handleEmbedViewWithTab={handleEmbedViewWithTab}
+          isReadOnly={isReadOnly}
         />
       );
     }
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx
index f313d1baaa0f..a0ec4d7c9a63 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx
@@ -23,6 +23,7 @@ interface AnonymousLinksTabProps {
   publicDomain: string;
   setSurveyUrl: (url: string) => void;
   locale: TUserLocale;
+  isReadOnly: boolean;
 }
 
 export const AnonymousLinksTab = ({
@@ -31,6 +32,7 @@ export const AnonymousLinksTab = ({
   publicDomain,
   setSurveyUrl,
   locale,
+  isReadOnly,
 }: AnonymousLinksTabProps) => {
   const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
   const router = useRouter();
@@ -220,6 +222,7 @@ export const AnonymousLinksTab = ({
                 publicDomain={publicDomain}
                 setSurveyUrl={setSurveyUrl}
                 locale={locale}
+                isReadOnly={isReadOnly}
               />
 
               <div className="w-full">
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx
index 7bc9321f67f3..2c520cefe6ba 100644
--- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx
@@ -16,6 +16,7 @@ interface SuccessViewProps {
   tabs: { id: string; label: string; icon: React.ElementType }[];
   handleViewChange: (view: string) => void;
   handleEmbedViewWithTab: (tabId: string) => void;
+  isReadOnly: boolean;
 }
 
 export const SuccessView: React.FC<SuccessViewProps> = ({
@@ -27,6 +28,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
   tabs,
   handleViewChange,
   handleEmbedViewWithTab,
+  isReadOnly,
 }) => {
   const { t } = useTranslate();
   const environmentId = survey.environmentId;
@@ -44,6 +46,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
             setSurveyUrl={setSurveyUrl}
             locale={user.locale}
             enforceSurveyUrlWidth
+            isReadOnly={isReadOnly}
           />
         </div>
       )}
diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
index 67a24063e857..7347cb8e5a1f 100644
--- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
+++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx
@@ -18,6 +18,7 @@ interface ShareSurveyLinkProps {
   setSurveyUrl: (url: string) => void;
   locale: TUserLocale;
   enforceSurveyUrlWidth?: boolean;
+  isReadOnly: boolean;
 }
 
 export const ShareSurveyLink = ({
@@ -27,6 +28,7 @@ export const ShareSurveyLink = ({
   setSurveyUrl,
   locale,
   enforceSurveyUrlWidth = false,
+  isReadOnly,
 }: ShareSurveyLinkProps) => {
   const { t } = useTranslate();
 
@@ -35,7 +37,7 @@ export const ShareSurveyLink = ({
     setSurveyUrl(url);
   };
 
-  const { refreshSingleUseId } = useSingleUseId(survey);
+  const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
 
   const getPreviewUrl = async () => {
     const previewUrl = new URL(surveyUrl);
diff --git a/apps/web/modules/survey/hooks/useSingleUseId.tsx b/apps/web/modules/survey/hooks/useSingleUseId.tsx
index 63ee15cfd623..650cea158e4d 100644
--- a/apps/web/modules/survey/hooks/useSingleUseId.tsx
+++ b/apps/web/modules/survey/hooks/useSingleUseId.tsx
@@ -1,40 +1,41 @@
-"use client";
-
 import { getFormattedErrorMessage } from "@/lib/utils/helper";
 import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
-import { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
+import type { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
 import { useCallback, useEffect, useState } from "react";
 import toast from "react-hot-toast";
-import { TSurvey } from "@formbricks/types/surveys/types";
+import type { TSurvey } from "@formbricks/types/surveys/types";
+
+export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolean) => {
+  const [singleUseId, setSingleUseId] = useState<string>();
 
-export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
-  const [singleUseId, setSingleUseId] = useState<string | undefined>(undefined);
+  const refreshSingleUseId = useCallback(async (): Promise<string | undefined> => {
+    if (isReadOnly || !survey.singleUse?.enabled) {
+      // If read‑only or singleUse disabled, just clear and bail out
+      setSingleUseId(undefined);
+      return undefined;
+    }
 
-  const refreshSingleUseId = useCallback(async () => {
-    if (survey.singleUse?.enabled) {
-      const response = await generateSingleUseIdsAction({
-        surveyId: survey.id,
-        isEncrypted: Boolean(survey.singleUse?.isEncrypted),
-        count: 1,
-      });
+    const response = await generateSingleUseIdsAction({
+      surveyId: survey.id,
+      isEncrypted: Boolean(survey.singleUse?.isEncrypted),
+      count: 1,
+    });
 
-      if (!!response?.data?.length) {
-        setSingleUseId(response.data[0]);
-        return response.data[0];
-      } else {
-        const errorMessage = getFormattedErrorMessage(response);
-        toast.error(errorMessage);
-        return undefined;
-      }
+    if (response?.data?.length) {
+      setSingleUseId(response.data[0]);
+      return response.data[0];
     } else {
-      setSingleUseId(undefined);
+      toast.error(getFormattedErrorMessage(response));
       return undefined;
     }
-  }, [survey]);
+  }, [survey, isReadOnly]);
 
   useEffect(() => {
     refreshSingleUseId();
-  }, [survey, refreshSingleUseId]);
+  }, [refreshSingleUseId]);
 
-  return { singleUseId, refreshSingleUseId };
+  return {
+    singleUseId: isReadOnly ? undefined : singleUseId,
+    refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
+  };
 };
diff --git a/apps/web/modules/survey/list/components/survey-card.tsx b/apps/web/modules/survey/list/components/survey-card.tsx
index 8014cc0bc129..126ad85e2d61 100644
--- a/apps/web/modules/survey/list/components/survey-card.tsx
+++ b/apps/web/modules/survey/list/components/survey-card.tsx
@@ -50,7 +50,7 @@ export const SurveyCard = ({
 
   const isSurveyCreationDeletionDisabled = isReadOnly;
 
-  const { refreshSingleUseId } = useSingleUseId(survey);
+  const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
 
   const linkHref = useMemo(() => {
     return survey.status === "draft"


Test patch:
diff --git a/apps/web/modules/survey/hooks/useSingleUseId.test.tsx b/apps/web/modules/survey/hooks/useSingleUseId.test.tsx
index 3250d724c648..b06d3657009e 100644
--- a/apps/web/modules/survey/hooks/useSingleUseId.test.tsx
+++ b/apps/web/modules/survey/hooks/useSingleUseId.test.tsx
@@ -31,10 +31,24 @@ describe("useSingleUseId", () => {
     },
   } as TSurvey;
 
+  test("should return early with undefined values when isReadOnly is true", async () => {
+    const { result } = renderHook(() => useSingleUseId(mockSurvey, true));
+
+    // Should immediately return undefined for singleUseId
+    expect(result.current.singleUseId).toBeUndefined();
+
+    // Should return a dummy function that resolves to undefined
+    const refreshResult = await result.current.refreshSingleUseId();
+    expect(refreshResult).toBeUndefined();
+
+    // Should not call the API when in read-only mode
+    expect(generateSingleUseIdsAction).not.toHaveBeenCalled();
+  });
+
   test("should initialize singleUseId to undefined", () => {
     vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
 
-    const { result } = renderHook(() => useSingleUseId(mockSurvey));
+    const { result } = renderHook(() => useSingleUseId(mockSurvey, false));
 
     // Right after mount, before the async effect resolves, singleUseId should be undefined
     expect(result.current.singleUseId).toBeUndefined();
@@ -43,7 +57,7 @@ describe("useSingleUseId", () => {
   test("should fetch and set singleUseId if singleUse is enabled", async () => {
     vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["mockSingleUseId"] });
 
-    const { result, rerender } = renderHook((props) => useSingleUseId(props), {
+    const { result, rerender } = renderHook((props) => useSingleUseId(props, false), {
       initialProps: mockSurvey,
     });
 
@@ -75,7 +89,7 @@ describe("useSingleUseId", () => {
       },
     } as TSurvey;
 
-    const { result } = renderHook(() => useSingleUseId(disabledSurvey));
+    const { result } = renderHook(() => useSingleUseId(disabledSurvey, false));
 
     await waitFor(() => {
       expect(result.current.singleUseId).toBeUndefined();
@@ -87,7 +101,7 @@ describe("useSingleUseId", () => {
   test("should show toast error if the API call fails", async () => {
     vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ serverError: "Something went wrong" });
 
-    const { result } = renderHook(() => useSingleUseId(mockSurvey));
+    const { result } = renderHook(() => useSingleUseId(mockSurvey, false));
 
     await waitFor(() => {
       expect(result.current.singleUseId).toBeUndefined();
@@ -101,7 +115,7 @@ describe("useSingleUseId", () => {
     // Set up the initial mock response
     vi.mocked(generateSingleUseIdsAction).mockResolvedValueOnce({ data: ["initialId"] });
 
-    const { result } = renderHook(() => useSingleUseId(mockSurvey));
+    const { result } = renderHook(() => useSingleUseId(mockSurvey, false));
 
     // We need to wait for the initial async effect to complete
     // This ensures the hook has time to update state with the first mock value
diff --git a/apps/web/modules/survey/list/components/survey-card.test.tsx b/apps/web/modules/survey/list/components/survey-card.test.tsx
index 67d8f6f163ee..ef33957b3cff 100644
--- a/apps/web/modules/survey/list/components/survey-card.test.tsx
+++ b/apps/web/modules/survey/list/components/survey-card.test.tsx
@@ -43,7 +43,6 @@ describe("SurveyCard", () => {
   const environmentId = "env123";
   const WEBAPP_URL = "http://example.com";
   const mockDeleteSurvey = vi.fn();
-  const mockDuplicateSurvey = vi.fn();
 
   afterEach(() => {
     cleanup();
@@ -56,7 +55,6 @@ describe("SurveyCard", () => {
         survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
         environmentId={environmentId}
         isReadOnly={false}
-        duplicateSurvey={mockDuplicateSurvey}
         deleteSurvey={mockDeleteSurvey}
         locale="en-US"
         publicDomain={WEBAPP_URL}
@@ -73,7 +71,6 @@ describe("SurveyCard", () => {
         survey={{ ...dummySurvey, status: "draft" } as unknown as TSurvey}
         environmentId={environmentId}
         isReadOnly={true}
-        duplicateSurvey={mockDuplicateSurvey}
         deleteSurvey={mockDeleteSurvey}
         locale="en-US"
         publicDomain={WEBAPP_URL}
@@ -90,7 +87,6 @@ describe("SurveyCard", () => {
         survey={{ ...dummySurvey, status: "inProgress" } as unknown as TSurvey}
         environmentId={environmentId}
         isReadOnly={false}
-        duplicateSurvey={mockDuplicateSurvey}
         deleteSurvey={mockDeleteSurvey}
         locale="en-US"
         publicDomain={WEBAPP_URL}