diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx index 0f6985dad8..0809609434 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx @@ -220,6 +220,9 @@ describe("MainNavigation", () => { const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" }); vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); + // Set up localStorage spy on the mocked localStorage + const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); + render(); // Find the avatar and get its parent div which acts as the trigger @@ -240,6 +243,9 @@ describe("MainNavigation", () => { const logoutButton = screen.getByText("common.logout"); await userEvent.click(logoutButton); + // Verify localStorage.removeItem is called with the correct key + expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id"); + expect(mockSignOut).toHaveBeenCalledWith({ reason: "user_initiated", redirectUrl: "/auth/login", @@ -247,9 +253,13 @@ describe("MainNavigation", () => { redirect: false, callbackUrl: "/auth/login", }); + await waitFor(() => { expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); }); + + // Clean up spy + removeItemSpy.mockRestore(); }); test("handles organization switching", async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 7ff46c6976..d492cfa87b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import FBLogo from "@/images/formbricks-wordmark.svg"; import { cn } from "@/lib/cn"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { getAccessFlags } from "@/lib/membership/utils"; import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; @@ -390,6 +391,8 @@ export const MainNavigation = ({ { + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + const route = await signOutWithAudit({ reason: "user_initiated", redirectUrl: "/auth/login", diff --git a/apps/web/app/ClientEnvironmentRedirect.test.tsx b/apps/web/app/ClientEnvironmentRedirect.test.tsx index 2f81f1ab3b..dffef885db 100644 --- a/apps/web/app/ClientEnvironmentRedirect.test.tsx +++ b/apps/web/app/ClientEnvironmentRedirect.test.tsx @@ -14,41 +14,64 @@ describe("ClientEnvironmentRedirect", () => { cleanup(); }); - test("should redirect to the provided environment ID when no last environment exists", () => { + test("should redirect to the first environment ID when no last environment exists", () => { const mockPush = vi.fn(); vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); // Mock localStorage const localStorageMock = { getItem: vi.fn().mockReturnValue(null), + removeItem: vi.fn(), }; + Object.defineProperty(window, "localStorage", { value: localStorageMock, }); - render(); + render(); expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id"); }); - test("should redirect to the last environment ID when it exists in localStorage", () => { + test("should redirect to the last environment ID when it exists in localStorage and is valid", () => { const mockPush = vi.fn(); vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); // Mock localStorage with a last environment ID const localStorageMock = { getItem: vi.fn().mockReturnValue("last-env-id"), + removeItem: vi.fn(), }; Object.defineProperty(window, "localStorage", { value: localStorageMock, }); - render(); + render(); expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id"); }); + test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage with an invalid environment ID + const localStorageMock = { + getItem: vi.fn().mockReturnValue("invalid-env-id"), + removeItem: vi.fn(), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1"); + }); + test("should update redirect when environment ID prop changes", () => { const mockPush = vi.fn(); vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); @@ -56,19 +79,20 @@ describe("ClientEnvironmentRedirect", () => { // Mock localStorage const localStorageMock = { getItem: vi.fn().mockReturnValue(null), + removeItem: vi.fn(), }; Object.defineProperty(window, "localStorage", { value: localStorageMock, }); - const { rerender } = render(); + const { rerender } = render(); expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id"); // Clear mock calls mockPush.mockClear(); // Rerender with new environment ID - rerender(); + rerender(); expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id"); }); }); diff --git a/apps/web/app/ClientEnvironmentRedirect.tsx b/apps/web/app/ClientEnvironmentRedirect.tsx index 8422172666..0c6afbd147 100644 --- a/apps/web/app/ClientEnvironmentRedirect.tsx +++ b/apps/web/app/ClientEnvironmentRedirect.tsx @@ -5,22 +5,23 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; interface ClientEnvironmentRedirectProps { - environmentId: string; + userEnvironments: string[]; } -const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => { +const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => { const router = useRouter(); useEffect(() => { const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS); - if (lastEnvironmentId) { - // Redirect to the last environment the user was in + if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) { router.push(`/environments/${lastEnvironmentId}`); } else { - router.push(`/environments/${environmentId}`); + // If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + router.push(`/environments/${userEnvironments[0]}`); } - }, [environmentId, router]); + }, [userEnvironments, router]); return null; }; diff --git a/apps/web/app/api/v1/management/storage/lib/utils.test.ts b/apps/web/app/api/v1/management/storage/lib/utils.test.ts index 33cff35de6..3372424ad9 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.test.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.test.ts @@ -1,13 +1,13 @@ -import { checkForRequiredFields } from "./utils"; -import { describe, test, expect } from "vitest"; +import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { NextRequest } from "next/server"; import { Session } from "next-auth"; +import { NextRequest } from "next/server"; +import { describe, expect, test } from "vitest"; import { vi } from "vitest"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { authenticateRequest } from "@/app/api/v1/auth"; +import { checkForRequiredFields } from "./utils"; import { checkAuth } from "./utils"; // Create mock response objects @@ -16,189 +16,197 @@ const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 }); vi.mock("@/app/api/v1/auth", () => ({ - authenticateRequest: vi.fn(), + authenticateRequest: vi.fn(), })); vi.mock("@/lib/environment/auth", () => ({ - hasUserEnvironmentAccess: vi.fn(), + hasUserEnvironmentAccess: vi.fn(), })); vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({ - hasPermission: vi.fn(), + hasPermission: vi.fn(), })); vi.mock("@/app/lib/api/response", () => ({ - responses: { - badRequestResponse: vi.fn(() => mockBadRequestResponse), - notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse), - unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse), - }, + responses: { + badRequestResponse: vi.fn(() => mockBadRequestResponse), + notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse), + unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse), + }, })); describe("checkForRequiredFields", () => { - test("should return undefined when all required fields are present", () => { - const result = checkForRequiredFields("env-123", "image/png", "test-file.png"); - expect(result).toBeUndefined(); - }); + test("should return undefined when all required fields are present", () => { + const result = checkForRequiredFields("env-123", "image/png", "test-file.png"); + expect(result).toBeUndefined(); + }); - test("should return bad request response when environmentId is missing", () => { - const result = checkForRequiredFields("", "image/png", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when environmentId is missing", () => { + const result = checkForRequiredFields("", "image/png", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); + expect(result).toBe(mockBadRequestResponse); + }); - test("should return bad request response when fileType is missing", () => { - const result = checkForRequiredFields("env-123", "", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when fileType is missing", () => { + const result = checkForRequiredFields("env-123", "", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); + expect(result).toBe(mockBadRequestResponse); + }); - test("should return bad request response when encodedFileName is missing", () => { - const result = checkForRequiredFields("env-123", "image/png", ""); - expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when encodedFileName is missing", () => { + const result = checkForRequiredFields("env-123", "image/png", ""); + expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); + expect(result).toBe(mockBadRequestResponse); + }); - test("should return bad request response when environmentId is undefined", () => { - const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when environmentId is undefined", () => { + const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); + expect(result).toBe(mockBadRequestResponse); + }); - test("should return bad request response when fileType is undefined", () => { - const result = checkForRequiredFields("env-123", undefined as any, "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when fileType is undefined", () => { + const result = checkForRequiredFields("env-123", undefined as any, "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); + expect(result).toBe(mockBadRequestResponse); + }); - test("should return bad request response when encodedFileName is undefined", () => { - const result = checkForRequiredFields("env-123", "image/png", undefined as any); - expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); - expect(result).toBe(mockBadRequestResponse); - }); + test("should return bad request response when encodedFileName is undefined", () => { + const result = checkForRequiredFields("env-123", "image/png", undefined as any); + expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); + expect(result).toBe(mockBadRequestResponse); + }); }); describe("checkAuth", () => { - const environmentId = "env-123"; - const mockRequest = new NextRequest("http://localhost:3000/api/test"); + const environmentId = "env-123"; + const mockRequest = new NextRequest("http://localhost:3000/api/test"); - test("returns notAuthenticatedResponse when no session and no authentication", async () => { - vi.mocked(authenticateRequest).mockResolvedValue(null); + test("returns notAuthenticatedResponse when no session and no authentication", async () => { + vi.mocked(authenticateRequest).mockResolvedValue(null); - const result = await checkAuth(null, environmentId, mockRequest); + const result = await checkAuth(null, environmentId, mockRequest); - expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); - expect(responses.notAuthenticatedResponse).toHaveBeenCalled(); - expect(result).toBe(mockNotAuthenticatedResponse); - }); + expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); + expect(responses.notAuthenticatedResponse).toHaveBeenCalled(); + expect(result).toBe(mockNotAuthenticatedResponse); + }); - test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => { - const mockAuthentication: TAuthenticationApiKey = { - type: "apiKey", - environmentPermissions: [ - { - environmentId: "env-123", - permission: "read", - environmentType: "development", - projectId: "project-1", - projectName: "Project 1", - }, - ], - hashedApiKey: "hashed-key", - apiKeyId: "api-key-id", - organizationId: "org-id", - organizationAccess: { - accessControl: {}, - }, - }; + test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => { + const mockAuthentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: [ + { + environmentId: "env-123", + permission: "read", + environmentType: "development", + projectId: "project-1", + projectName: "Project 1", + }, + ], + hashedApiKey: "hashed-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + organizationAccess: { + accessControl: {}, + }, + }; - vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication); - vi.mocked(hasPermission).mockReturnValue(false); + vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication); + vi.mocked(hasPermission).mockReturnValue(false); - const result = await checkAuth(null, environmentId, mockRequest); + const result = await checkAuth(null, environmentId, mockRequest); - expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); - expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST"); - expect(responses.unauthorizedResponse).toHaveBeenCalled(); - expect(result).toBe(mockUnauthorizedResponse); - }); + expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); + expect(hasPermission).toHaveBeenCalledWith( + mockAuthentication.environmentPermissions, + environmentId, + "POST" + ); + expect(responses.unauthorizedResponse).toHaveBeenCalled(); + expect(result).toBe(mockUnauthorizedResponse); + }); - test("returns undefined when no session and authentication has POST permission", async () => { - const mockAuthentication: TAuthenticationApiKey = { - type: "apiKey", - environmentPermissions: [ - { - environmentId: "env-123", - permission: "write", - environmentType: "development", - projectId: "project-1", - projectName: "Project 1", - }, - ], - hashedApiKey: "hashed-key", - apiKeyId: "api-key-id", - organizationId: "org-id", - organizationAccess: { - accessControl: {}, - }, - }; + test("returns undefined when no session and authentication has POST permission", async () => { + const mockAuthentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: [ + { + environmentId: "env-123", + permission: "write", + environmentType: "development", + projectId: "project-1", + projectName: "Project 1", + }, + ], + hashedApiKey: "hashed-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + organizationAccess: { + accessControl: {}, + }, + }; - vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication); - vi.mocked(hasPermission).mockReturnValue(true); + vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication); + vi.mocked(hasPermission).mockReturnValue(true); - const result = await checkAuth(null, environmentId, mockRequest); + const result = await checkAuth(null, environmentId, mockRequest); - expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); - expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST"); - expect(result).toBeUndefined(); - }); + expect(authenticateRequest).toHaveBeenCalledWith(mockRequest); + expect(hasPermission).toHaveBeenCalledWith( + mockAuthentication.environmentPermissions, + environmentId, + "POST" + ); + expect(result).toBeUndefined(); + }); - test("returns unauthorizedResponse when session exists but user lacks environment access", async () => { - const mockSession: Session = { - user: { - id: "user-123", - }, - expires: "2024-12-31T23:59:59.999Z", - }; + test("returns unauthorizedResponse when session exists but user lacks environment access", async () => { + const mockSession: Session = { + user: { + id: "user-123", + }, + expires: "2024-12-31T23:59:59.999Z", + }; - vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false); - const result = await checkAuth(mockSession, environmentId, mockRequest); + const result = await checkAuth(mockSession, environmentId, mockRequest); - expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); - expect(responses.unauthorizedResponse).toHaveBeenCalled(); - expect(result).toBe(mockUnauthorizedResponse); - }); + expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); + expect(responses.unauthorizedResponse).toHaveBeenCalled(); + expect(result).toBe(mockUnauthorizedResponse); + }); - test("returns undefined when session exists and user has environment access", async () => { - const mockSession: Session = { - user: { - id: "user-123", - }, - expires: "2024-12-31T23:59:59.999Z", - }; + test("returns undefined when session exists and user has environment access", async () => { + const mockSession: Session = { + user: { + id: "user-123", + }, + expires: "2024-12-31T23:59:59.999Z", + }; - vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); - const result = await checkAuth(mockSession, environmentId, mockRequest); + const result = await checkAuth(mockSession, environmentId, mockRequest); - expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); - expect(result).toBeUndefined(); - }); + expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); + expect(result).toBeUndefined(); + }); - test("does not call authenticateRequest when session exists", async () => { - const mockSession: Session = { - user: { - id: "user-123", - }, - expires: "2024-12-31T23:59:59.999Z", - }; + test("does not call authenticateRequest when session exists", async () => { + const mockSession: Session = { + user: { + id: "user-123", + }, + expires: "2024-12-31T23:59:59.999Z", + }; - vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); - await checkAuth(mockSession, environmentId, mockRequest); + await checkAuth(mockSession, environmentId, mockRequest); - expect(authenticateRequest).not.toHaveBeenCalled(); - expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); - }); -}); \ No newline at end of file + expect(authenticateRequest).not.toHaveBeenCalled(); + expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); + }); +}); diff --git a/apps/web/app/api/v1/management/storage/lib/utils.ts b/apps/web/app/api/v1/management/storage/lib/utils.ts index 36ea9ad20b..dcd014bfa2 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.ts @@ -1,38 +1,41 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; -import { NextRequest } from "next/server"; -import { Session } from "next-auth"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { Session } from "next-auth"; +import { NextRequest } from "next/server"; +export const checkForRequiredFields = ( + environmentId: string, + fileType: string, + encodedFileName: string +): Response | undefined => { + if (!environmentId) { + return responses.badRequestResponse("environmentId is required"); + } -export const checkForRequiredFields = (environmentId: string, fileType: string, encodedFileName: string): Response | undefined => { - if (!environmentId) { - return responses.badRequestResponse("environmentId is required"); - } + if (!fileType) { + return responses.badRequestResponse("contentType is required"); + } - if (!fileType) { - return responses.badRequestResponse("contentType is required"); - } - - if (!encodedFileName) { - return responses.badRequestResponse("fileName is required"); - } + if (!encodedFileName) { + return responses.badRequestResponse("fileName is required"); + } }; export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => { - if (!session) { - //check whether its using API key - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); + if (!session) { + //check whether its using API key + const authentication = await authenticateRequest(request); + if (!authentication) return responses.notAuthenticatedResponse(); - if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return responses.unauthorizedResponse(); - } - } else { - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); - if (!isUserAuthorized) { - return responses.unauthorizedResponse(); - } + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return responses.unauthorizedResponse(); } -}; \ No newline at end of file + } else { + const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + if (!isUserAuthorized) { + return responses.unauthorizedResponse(); + } + } +}; diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts index 2279614b7f..db0bc62de6 100644 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ b/apps/web/app/api/v1/management/storage/local/route.ts @@ -1,6 +1,7 @@ // headers -> "Content-Type" should be present and set to a valid MIME type // body -> should be a valid file object (buffer) // method -> PUT (to be the same as the signedUrl method) +import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; import { responses } from "@/app/lib/api/response"; import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; import { validateLocalSignedUrl } from "@/lib/crypto"; @@ -10,7 +11,6 @@ import { authOptions } from "@/modules/auth/lib/authOptions"; import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; -import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; export const POST = async (req: NextRequest): Promise => { if (!ENCRYPTION_KEY) { diff --git a/apps/web/app/api/v1/management/storage/route.ts b/apps/web/app/api/v1/management/storage/route.ts index b8321eff9d..38583e3e4b 100644 --- a/apps/web/app/api/v1/management/storage/route.ts +++ b/apps/web/app/api/v1/management/storage/route.ts @@ -1,3 +1,4 @@ +import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; import { responses } from "@/app/lib/api/response"; import { validateFile } from "@/lib/fileValidation"; import { authOptions } from "@/modules/auth/lib/authOptions"; @@ -5,8 +6,6 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { getSignedUrlForPublicFile } from "./lib/getSignedUrl"; -import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; - // api endpoint for uploading public files // uploaded files will be public, anyone can access the file @@ -14,7 +13,6 @@ import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/stora // use this to upload files for a specific resource, e.g. a user profile picture or a survey // this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage - export const POST = async (request: NextRequest): Promise => { let storageInput; @@ -34,7 +32,6 @@ export const POST = async (request: NextRequest): Promise => { const authResponse = await checkAuth(session, environmentId, request); if (authResponse) return authResponse; - // Perform server-side file validation first to block dangerous file types const fileValidation = validateFile(fileName, fileType); if (!fileValidation.valid) { diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index 3f0917864f..96bdf2b7f0 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -3006,12 +3006,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => { t("templates.understand_low_engagement_question_1_choice_4"), t("templates.understand_low_engagement_question_1_choice_5"), ], - choiceIds: [ - reusableOptionIds[0], - reusableOptionIds[1], - reusableOptionIds[2], - reusableOptionIds[3], - ], + choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]], headline: t("templates.understand_low_engagement_question_1_headline"), required: true, containsOther: true, diff --git a/apps/web/app/page.test.tsx b/apps/web/app/page.test.tsx index a75ad3e36c..a1d896880a 100644 --- a/apps/web/app/page.test.tsx +++ b/apps/web/app/page.test.tsx @@ -3,12 +3,12 @@ import { cleanup } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { TMembership } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; import { TUser } from "@formbricks/types/user"; import Page from "./page"; -// Mock dependencies -vi.mock("@/lib/environment/service", () => ({ - getFirstEnvironmentIdByUserId: vi.fn(), +vi.mock("@/lib/project/service", () => ({ + getProjectEnvironmentsByOrganizationIds: vi.fn(), })); vi.mock("@/lib/instance/service", () => ({ @@ -48,8 +48,11 @@ vi.mock("@/modules/ui/components/client-logout", () => ({ })); vi.mock("@/app/ClientEnvironmentRedirect", () => ({ - default: ({ environmentId }: { environmentId: string }) => ( -
Environment ID: {environmentId}
+ default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => ( +
+ Environment ID: {environmentId} + {userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`} +
), })); @@ -149,7 +152,7 @@ describe("Page", () => { const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getUser } = await import("@/lib/user/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service"); - const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); + const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); const { redirect } = await import("next/navigation"); @@ -204,13 +207,23 @@ describe("Page", () => { role: "owner", }; + const mockUserProjects = [ + { + id: "test-project-id", + name: "Test Project", + environments: [], + }, + ]; + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "test-user-id" }, } as any); vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( + mockUserProjects as unknown as TProject[] + ); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); - vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getAccessFlags).mockReturnValue({ isManager: false, @@ -228,8 +241,8 @@ describe("Page", () => { const { getServerSession } = await import("next-auth"); const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getUser } = await import("@/lib/user/service"); + const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service"); - const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); const { redirect } = await import("next/navigation"); @@ -284,13 +297,23 @@ describe("Page", () => { role: "member", }; + const mockUserProjects = [ + { + id: "test-project-id", + name: "Test Project", + environments: [], + }, + ]; + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "test-user-id" }, } as any); vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue( + mockUserProjects as unknown as TProject[] + ); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); - vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getAccessFlags).mockReturnValue({ isManager: false, @@ -309,9 +332,9 @@ describe("Page", () => { const { getIsFreshInstance } = await import("@/lib/instance/service"); const { getUser } = await import("@/lib/user/service"); const { getOrganizationsByUserId } = await import("@/lib/organization/service"); - const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); const { getAccessFlags } = await import("@/lib/membership/utils"); + const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); const { render } = await import("@testing-library/react"); const mockUser: TUser = { @@ -364,7 +387,43 @@ describe("Page", () => { role: "member", }; - const mockEnvironmentId = "test-env-id"; + const mockUserProjects = [ + { + id: "project-1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "test-org-id", + styling: { allowStyleOverwrite: true }, + recontactDays: 0, + inAppSurveyBranding: false, + linkSurveyBranding: false, + config: { channel: "link" as const, industry: "saas" as const }, + placement: "bottomRight" as const, + clickOutsideClose: false, + darkOverlay: false, + languages: [], + logo: null, + environments: [ + { + id: "test-env-id", + type: "production" as const, + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + appSetupCompleted: true, + }, + { + id: "test-env-dev", + type: "development" as const, + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + appSetupCompleted: true, + }, + ], + }, + ] as any; vi.mocked(getServerSession).mockResolvedValue({ user: { id: "test-user-id" }, @@ -372,8 +431,8 @@ describe("Page", () => { vi.mocked(getIsFreshInstance).mockResolvedValue(false); vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); - vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects); vi.mocked(getAccessFlags).mockReturnValue({ isManager: false, isOwner: false, @@ -385,7 +444,7 @@ describe("Page", () => { const { container } = render(result); expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent( - `Environment ID: ${mockEnvironmentId}` + `User Environments: test-env-id, test-env-dev` ); }); }); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e062110338..7410ec6596 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,9 +1,9 @@ import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect"; -import { getFirstEnvironmentIdByUserId } from "@/lib/environment/service"; import { getIsFreshInstance } from "@/lib/instance/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service"; import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { ClientLogout } from "@/modules/ui/components/client-logout"; @@ -34,16 +34,34 @@ const Page = async () => { return redirect("/setup/organization/create"); } - let environmentId: string | null = null; - environmentId = await getFirstEnvironmentIdByUserId(session.user.id); + const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id)); + + // Flatten all environments from all projects across all organizations + const allEnvironments = projectsByOrg.flatMap((project) => project.environments); + + // Find first production environment and collect all other environment IDs in one pass + const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce( + (acc, env) => { + if (env.type === "production" && !acc.firstProductionEnvironmentId) { + acc.firstProductionEnvironmentId = env.id; + } else { + acc.otherEnvironmentIds.add(env.id); + } + return acc; + }, + { firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set() } + ); + + const userEnvironments = [...otherEnvironmentIds]; const currentUserMembership = await getMembershipByUserIdOrganizationId( session.user.id, userOrganizations[0].id ); + const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role); - if (!environmentId) { + if (!firstProductionEnvironmentId) { if (isOwner || isManager) { return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`); } else { @@ -51,7 +69,10 @@ const Page = async () => { } } - return ; + // Put the first production environment at the front of the array + const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments]; + + return ; }; export default Page; diff --git a/apps/web/lib/project/service.test.ts b/apps/web/lib/project/service.test.ts index 34cb111332..a18443d2e0 100644 --- a/apps/web/lib/project/service.test.ts +++ b/apps/web/lib/project/service.test.ts @@ -1,10 +1,16 @@ import { createId } from "@paralleldrive/cuid2"; -import { Prisma } from "@prisma/client"; +import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client"; import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import { ITEMS_PER_PAGE } from "../constants"; -import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service"; +import { + getProject, + getProjectByEnvironmentId, + getProjectEnvironmentsByOrganizationIds, + getProjects, + getUserProjects, +} from "./service"; vi.mock("@formbricks/database", () => ({ prisma: { @@ -35,13 +41,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }; vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject); @@ -86,13 +99,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }; vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject); @@ -144,13 +164,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, { id: createId(), @@ -162,23 +189,29 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, ]; vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - id: createId(), userId, organizationId, - role: "admin", - createdAt: new Date(), - updatedAt: new Date(), + role: OrganizationRole.owner, + accepted: true, + deprecatedRole: null, }); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); @@ -210,23 +243,29 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, ]; vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - id: createId(), userId, organizationId, - role: "member", - createdAt: new Date(), - updatedAt: new Date(), + role: OrganizationRole.member, + accepted: true, + deprecatedRole: null, }); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); @@ -278,23 +317,29 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, ]; vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - id: createId(), userId, organizationId, - role: "admin", - createdAt: new Date(), - updatedAt: new Date(), + role: OrganizationRole.owner, + accepted: true, + deprecatedRole: null, }); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); @@ -326,13 +371,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, { id: createId(), @@ -344,13 +396,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, ]; @@ -382,13 +441,20 @@ describe("Project Service", () => { recontactDays: 0, linkSurveyBranding: true, inAppSurveyBranding: true, - config: {}, - placement: "bottomRight", + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, clickOutsideClose: true, darkOverlay: false, environments: [], - styling: {}, + styling: { + allowStyleOverwrite: true, + }, logo: null, + brandColor: null, + highlightBorderColor: null, }, ]; @@ -418,4 +484,68 @@ describe("Project Service", () => { await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError); }); + + test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const mockProjects = [ + { + environments: [], + }, + { + environments: [], + }, + ]; + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); + + const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: { + in: [organizationId1, organizationId2], + }, + }, + select: { environments: true }, + }); + }); + + test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + + vi.mocked(prisma.project.findMany).mockResolvedValue([]); + + const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]); + + expect(result).toEqual([]); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: { + in: [organizationId1, organizationId2], + }, + }, + select: { environments: true }, + }); + }); + + test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError); + + await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow( + DatabaseError + ); + }); + + test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => { + await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError); + }); }); diff --git a/apps/web/lib/project/service.ts b/apps/web/lib/project/service.ts index 263cce84b3..93521f4337 100644 --- a/apps/web/lib/project/service.ts +++ b/apps/web/lib/project/service.ts @@ -170,3 +170,31 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st throw error; } }); + +export const getProjectEnvironmentsByOrganizationIds = reactCache( + async (organizationIds: string[]): Promise[]> => { + validateInputs([organizationIds, ZId.array()]); + try { + if (organizationIds.length === 0) { + return []; + } + + const projects = await prisma.project.findMany({ + where: { + organizationId: { + in: organizationIds, + }, + }, + select: { environments: true }, + }); + + return projects; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(err.message); + } + + throw err; + } + } +); diff --git a/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx b/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx index fd602b3e3e..13c985940f 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx +++ b/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx @@ -1,3 +1,4 @@ +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; import { TOrganization } from "@formbricks/types/organizations"; @@ -78,6 +79,11 @@ describe("DeleteAccountModal", () => { .spyOn(actions, "deleteUserAction") .mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here + Object.defineProperty(window, "localStorage", { + writable: true, + value: { removeItem: vi.fn() }, + }); + // Mock window.location.replace Object.defineProperty(window, "location", { writable: true, @@ -94,6 +100,8 @@ describe("DeleteAccountModal", () => { /> ); + const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); + const input = screen.getByTestId("deleteAccountConfirmation"); fireEvent.change(input, { target: { value: mockUser.email } }); @@ -106,6 +114,7 @@ describe("DeleteAccountModal", () => { reason: "account_deletion", redirect: false, // Updated to match new implementation }); + expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); expect(window.location.replace).toHaveBeenCalledWith("/auth/login"); expect(mockSetOpen).toHaveBeenCalledWith(false); }); @@ -116,6 +125,11 @@ describe("DeleteAccountModal", () => { .spyOn(actions, "deleteUserAction") .mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here + Object.defineProperty(window, "localStorage", { + writable: true, + value: { removeItem: vi.fn() }, + }); + Object.defineProperty(window, "location", { writable: true, value: { replace: vi.fn() }, @@ -137,12 +151,15 @@ describe("DeleteAccountModal", () => { const form = screen.getByTestId("deleteAccountForm"); fireEvent.submit(form); + const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); + await waitFor(() => { expect(deleteUserAction).toHaveBeenCalled(); expect(mockSignOut).toHaveBeenCalledWith({ reason: "account_deletion", redirect: false, // Updated to match new implementation }); + expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); expect(window.location.replace).toHaveBeenCalledWith( "https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2" ); diff --git a/apps/web/modules/account/components/DeleteAccountModal/index.tsx b/apps/web/modules/account/components/DeleteAccountModal/index.tsx index 6aa9d8f1a7..95b35179ba 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/index.tsx +++ b/apps/web/modules/account/components/DeleteAccountModal/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { Input } from "@/modules/ui/components/input"; @@ -38,6 +39,8 @@ export const DeleteAccountModal = ({ setDeleting(true); await deleteUserAction(); + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + // Sign out with account deletion reason (no automatic redirect) await signOutWithAudit({ reason: "account_deletion", diff --git a/apps/web/modules/ui/components/client-logout/index.test.tsx b/apps/web/modules/ui/components/client-logout/index.test.tsx index ad2b23e789..332316f5cc 100644 --- a/apps/web/modules/ui/components/client-logout/index.test.tsx +++ b/apps/web/modules/ui/components/client-logout/index.test.tsx @@ -1,17 +1,61 @@ +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { render } from "@testing-library/react"; -import { signOut } from "next-auth/react"; -import { describe, expect, test, vi } from "vitest"; +import { type MockedFunction, beforeEach, describe, expect, test, vi } from "vitest"; import { ClientLogout } from "./index"; +// Mock the localStorage +const mockRemoveItem = vi.fn(); +Object.defineProperty(window, "localStorage", { + value: { + removeItem: mockRemoveItem, + }, +}); + // Mock next-auth/react -vi.mock("next-auth/react", () => ({ - signOut: vi.fn(), +const mockSignOut = vi.fn(); +vi.mock("@/modules/auth/hooks/use-sign-out", () => ({ + useSignOut: vi.fn(), })); +const mockUseSignOut = useSignOut as MockedFunction; + describe("ClientLogout", () => { - test("calls signOut on render", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSignOut.mockReturnValue({ + signOut: mockSignOut, + }); + }); + + test("calls signOut with correct parameters on render", () => { render(); - expect(signOut).toHaveBeenCalled(); + + expect(mockUseSignOut).toHaveBeenCalled(); + + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "forced_logout", + redirectUrl: "/auth/login", + redirect: false, + callbackUrl: "/auth/login", + }); + }); + + test("handles missing userId and userEmail", () => { + render(); + + expect(mockUseSignOut).toHaveBeenCalled(); + + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "forced_logout", + redirectUrl: "/auth/login", + redirect: false, + callbackUrl: "/auth/login", + }); + }); + + test("removes environment ID from localStorage", () => { + render(); + expect(mockRemoveItem).toHaveBeenCalledWith("formbricks-environment-id"); }); test("renders null", () => { diff --git a/apps/web/modules/ui/components/client-logout/index.tsx b/apps/web/modules/ui/components/client-logout/index.tsx index 04b0c45810..a6b76e7a71 100644 --- a/apps/web/modules/ui/components/client-logout/index.tsx +++ b/apps/web/modules/ui/components/client-logout/index.tsx @@ -1,11 +1,20 @@ "use client"; -import { signOut } from "next-auth/react"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useEffect } from "react"; export const ClientLogout = () => { + const { signOut: signOutWithAudit } = useSignOut(); + useEffect(() => { - signOut(); + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + signOutWithAudit({ + reason: "forced_logout", + redirectUrl: "/auth/login", + redirect: false, + callbackUrl: "/auth/login", + }); }); return null; };