=> {
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;
};