Compare commits

..

6 Commits

Author SHA1 Message Date
Matthias Nannt
8459faed55 solve merge conflict 2025-06-18 13:44:14 +02:00
Johannes
8c3e816ccd fix: remove Formbricks branding from Link Pages (#5989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 16:18:25 +00:00
Anshuman Pandey
6ddc91ee85 fix: deletes local storage environment id on logout (#5957) 2025-06-16 14:01:16 +00:00
Saurav Jain
14023ca8a9 fix: keyboard accessibility issue (#3768) (#5941) 2025-06-16 15:45:52 +02:00
Dhruwang Jariwala
385e8a4262 fix: Airtable fix (#5976)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 12:37:05 +00:00
Piyush Jain
c4a32bce4f switch staging to internal lb 2025-05-26 19:39:34 +05:30
50 changed files with 1058 additions and 401 deletions

View File

@@ -125,7 +125,7 @@ export const OnboardingSetupInstructions = ({
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
<p className="mt-6 -mb-1 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -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(<MainNavigation {...defaultProps} />);
// 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 () => {

View File

@@ -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";
@@ -265,7 +266,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -390,6 +391,8 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",

View File

@@ -156,7 +156,7 @@ export const ShareEmbedSurvey = ({
<Badge
size="tiny"
type="success"
className="absolute right-3 top-3"
className="absolute top-3 right-3"
text={t("common.new")}
/>
</button>

View File

@@ -38,7 +38,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
</TooltipProvider>
</div>
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
<div className="px-4 text-right md:mr-1 md:pr-6 md:pl-6">
{t("environments.surveys.summary.drop_offs")}
</div>
</div>
@@ -63,10 +63,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)}
</p>
</div>
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
<div className="px-4 py-2 text-right font-mono font-medium whitespace-pre-wrap md:px-6">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
<div className="px-4 py-2 text-right font-mono font-medium whitespace-pre-wrap md:px-6">
{quesDropOff.impressions}
</div>
<div className="px-4 py-2 text-right md:px-6">

View File

@@ -87,7 +87,7 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
<DropdownMenuTrigger
asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">
{t("environments.surveys.summary.share_results")}

View File

@@ -63,7 +63,7 @@ export const SurveyStatusDropdown = ({
<>
{survey.status === "draft" ? (
<div className="flex items-center">
<p className="text-sm italic text-slate-600">{t("common.draft")}</p>
<p className="text-sm text-slate-600 italic">{t("common.draft")}</p>
</div>
) : (
<Select

View File

@@ -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(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
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(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
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(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
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(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

@@ -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;
};

View File

@@ -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);
});
});
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});

View File

@@ -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();
}
};
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
};

View File

@@ -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<Response> => {
if (!ENCRYPTION_KEY) {

View File

@@ -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<Response> => {
let storageInput;
@@ -34,7 +32,6 @@ export const POST = async (request: NextRequest): Promise<Response> => {
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) {

View File

@@ -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,

View File

@@ -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 }) => (
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => (
<div data-testid="client-environment-redirect">
Environment ID: {environmentId}
{userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`}
</div>
),
}));
@@ -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`
);
});
});

View File

@@ -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<string>() }
);
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 <ClientEnvironmentRedirect environmentId={environmentId} />;
// Put the first production environment at the front of the array
const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments];
return <ClientEnvironmentRedirect userEnvironments={sortedUserEnvironments} />;
};
export default Page;

View File

@@ -1,3 +1,5 @@
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
export default LinkSurveyNotFound;
export default function NotFound() {
return <LinkSurveyNotFound />;
}

View File

@@ -13,7 +13,8 @@ import {
ZIntegrationAirtableTokenSchema,
} from "@formbricks/types/integration/airtable";
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
import { delay } from "../utils/promises";
import { truncateText } from "../utils/strings";
export const getBases = async (key: string) => {
@@ -99,7 +100,11 @@ export const getAirtableToken = async (environmentId: string) => {
});
if (!newToken) {
throw new Error("Failed to create new token");
logger.error("Failed to fetch new Airtable token", {
environmentId,
airtableIntegration,
});
throw new Error("Failed to fetch new Airtable token");
}
await createOrUpdateIntegration(environmentId, {
@@ -116,9 +121,11 @@ export const getAirtableToken = async (environmentId: string) => {
return access_token;
} catch (error) {
await deleteIntegration(environmentId);
throw new Error("invalid token");
logger.error("Failed to get Airtable token", {
environmentId,
error,
});
throw new Error("Failed to get Airtable token");
}
};
@@ -178,6 +185,18 @@ const addField = async (
return await req.json();
};
const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => {
const req = await tableFetcher(key, baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
const currentTable = tables.find((t) => t.id === tableId);
if (!currentTable) {
throw new Error(`Table with ID ${tableId} not found`);
}
return new Set(currentTable.fields.map((f) => f.name));
};
export const writeData = async (
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
@@ -186,6 +205,7 @@ export const writeData = async (
const responses = values[0];
const questions = values[1];
// 1) Build the record payload
const data: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
data[questions[i]] =
@@ -194,34 +214,73 @@ export const writeData = async (
: responses[i];
}
const req = await tableFetcher(key, configData.baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
// 2) Figure out which fields need creating
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
const currentTable = tables.find((table) => table.id === configData.tableId);
if (currentTable) {
const currentFields = new Set(currentTable.fields.map((field) => field.name));
const fieldsToCreate = new Set<string>();
for (const field of questions) {
const hasField = currentFields.has(field);
if (!hasField) {
fieldsToCreate.add(field);
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
if (fieldsToCreate.length > 0) {
// Sequential processing with delays
const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit)
for (let i = 0; i < fieldsToCreate.length; i++) {
const fieldName = fieldsToCreate[i];
const createRes = await addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
});
if (createRes?.error) {
throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`);
}
// Add delay between requests (except for the last one)
if (i < fieldsToCreate.length - 1) {
await delay(DELAY_BETWEEN_REQUESTS);
}
}
if (fieldsToCreate.size > 0) {
const createFieldPromise: Promise<any>[] = [];
fieldsToCreate.forEach((fieldName) => {
createFieldPromise.push(
addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
})
);
});
// 4) Wait for the new fields to show up
await waitForFieldsToExist(key, configData, fieldsToCreate);
}
await Promise.all(createFieldPromise);
// 5) Finally, add the records
await addRecords(key, configData.baseId, configData.tableId, data);
};
async function waitForFieldsToExist(
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
fieldNames: string[],
maxRetries = 5,
intervalMs = 2000
) {
let existingFields: Set<string> = new Set(),
missingFields: string[] = [];
for (let attempt = 1; attempt <= maxRetries; attempt++) {
existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
missingFields = fieldNames.filter((f) => !existingFields.has(f));
if (missingFields.length === 0) {
return;
}
if (attempt < maxRetries) {
logger.error(
`Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join(
", "
)}], retrying in ${intervalMs / 1000}s…`
);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
await addRecords(key, configData.baseId, configData.tableId, data);
};
throw new Error(
`Timed out waiting for ${missingFields.length} field(s) [${missingFields.join(
", "
)}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]`
);
}

View File

@@ -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);
});
});

View File

@@ -170,3 +170,31 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
throw error;
}
});
export const getProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
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;
}
}
);

View File

@@ -1892,12 +1892,12 @@
},
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.",
"completed": "This free & open-source survey has been closed.",
"create_your_own": "Create your own",
"completed": "This survey is closed.",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This free & open-source survey is temporarily paused.",
"paused": "This survey is temporarily paused.",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",

View File

@@ -1892,12 +1892,12 @@
},
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito gratuito e de código aberto foi encerrado.",
"create_your_own": "Crie o seu próprio",
"completed": "Este inquérito está encerrado.",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito gratuito e de código aberto está temporariamente pausado.",
"paused": "Este inquérito está temporariamente suspenso.",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",

View File

@@ -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"
);

View File

@@ -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",

View File

@@ -10,7 +10,7 @@ export const SignupWithoutVerificationSuccessPage = async ({ searchParams }) =>
return (
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
<h1 className="mb-4 text-center leading-2 font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">

View File

@@ -15,7 +15,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
return (
<FormWrapper>
<>
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
{t("auth.verification-requested.please_confirm_your_email_address")}
</h1>
<p className="text-center text-sm text-slate-700">

View File

@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -232,7 +232,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm italic text-slate-500">
<p className="text-sm text-slate-500 italic">
{t("environments.project.languages.no_language_found")}
</p>
)}

View File

@@ -60,11 +60,7 @@ const getRatingContent = (scale: string, i: number, range: number, isColorCoding
);
}
if (scale === "number") {
return (
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
{i + 1}
</Text>
);
return <Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">{i + 1}</Text>;
}
if (scale === "star") {
return <Text className="m-auto text-3xl"></Text>;
@@ -98,8 +94,8 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Consent:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color m-0 text-sm font-normal leading-6">
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
<Container className="text-question-color m-0 text-sm leading-6 font-normal">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
@@ -185,8 +181,8 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.CTA:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
<Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
<div
className="m-0 p-0"
dangerouslySetInnerHTML={{
@@ -232,8 +228,8 @@ export async function PreviewEmailTemplate({
{ "rounded-l-lg border-l": i === 0 },
{ "rounded-r-lg": i === firstQuestion.range - 1 },
firstQuestion.isColorCodingEnabled &&
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "number" &&
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
firstQuestion.scale === "star" && "border-transparent"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
@@ -325,13 +321,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -379,11 +375,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 break-words px-4 py-2" />
<Column className="w-40 px-4 py-2 break-words" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
key={getLocalizedValue(column, "default")}>
{getLocalizedValue(column, "default")}
</Column>
@@ -395,7 +391,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={getLocalizedValue(row, "default")}>
<Column className="w-40 break-words px-4 py-2">
<Column className="w-40 px-4 py-2 break-words">
{getLocalizedValue(row, "default")}
</Column>
{firstQuestion.columns.map((_) => {

View File

@@ -220,7 +220,7 @@ export const RecallWrapper = ({
}
parts.push(
<span
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
className="z-30 flex h-fit cursor-pointer justify-center rounded-md bg-slate-100 text-sm whitespace-pre text-transparent"
key={`recall-${parts.length}`}>
{"@" + label}
</span>
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
<Button
variant="ghost"
type="button"
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
className="absolute top-full right-2 z-[1] flex h-6 cursor-pointer items-center rounded-t-none rounded-b-lg bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
onClick={(e) => {
e.preventDefault();
setShowFallbackInput(true);

View File

@@ -82,7 +82,7 @@ export const LinkSurveyWrapper = ({
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />

View File

@@ -118,7 +118,7 @@ export const LinkSurvey = ({
}, [survey.isVerifyEmailEnabled, verifiedEmail]);
if (hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
}
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified") {

View File

@@ -126,4 +126,19 @@ describe("SurveyInactive", () => {
expect(screen.getByTestId("button")).toBeInTheDocument();
expect(screen.getByTestId("mock-image")).toBeInTheDocument();
});
test("shows branding when linkSurveyBranding is true", async () => {
const Component = await SurveyInactive({ status: "paused", project: { linkSurveyBranding: true } });
render(Component);
expect(screen.getByTestId("mock-image")).toBeInTheDocument();
expect(screen.getByTestId("footer-link")).toBeInTheDocument();
expect(screen.getByTestId("footer-link")).toHaveAttribute("href", "https://formbricks.com");
});
test("hides branding when linkSurveyBranding is false", async () => {
const Component = await SurveyInactive({ status: "paused", project: { linkSurveyBranding: false } });
render(Component);
expect(screen.queryByTestId("mock-image")).not.toBeInTheDocument();
expect(screen.queryByTestId("footer-link")).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import { Button } from "@/modules/ui/components/button";
import { getTranslate } from "@/tolgee/server";
import { Project } from "@prisma/client";
import { CheckCircle2Icon, HelpCircleIcon, PauseCircleIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -9,9 +10,11 @@ import footerLogo from "../lib/footerlogo.svg";
export const SurveyInactive = async ({
status,
surveyClosedMessage,
project,
}: {
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
surveyClosedMessage?: TSurveyClosedMessage | null;
project?: Pick<Project, "linkSurveyBranding">;
}) => {
const t = await getTranslate();
const icons = {
@@ -28,10 +31,15 @@ export const SurveyInactive = async ({
"response submitted": t("s.response_submitted"),
};
const showCTA =
status !== "link invalid" &&
status !== "response submitted" &&
((status !== "paused" && status !== "completed") || project?.linkSurveyBranding || !project) &&
!(status === "completed" && surveyClosedMessage);
return (
<div className="flex h-full flex-col items-center justify-between bg-gradient-to-br from-slate-200 to-slate-50 px-4 py-8 text-center">
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
{icons[status]}
<h1 className="text-4xl font-bold text-slate-800">
{status === "completed" && surveyClosedMessage
@@ -43,19 +51,19 @@ export const SurveyInactive = async ({
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) &&
status !== "link invalid" &&
status !== "response submitted" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
{showCTA && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
{(!project || project.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
</div>
);
};

View File

@@ -13,13 +13,17 @@ vi.mock("next/link", () => ({
default: vi.fn(({ children, href }) => <a href={href}>{children}</a>),
}));
const mockProject = {
linkSurveyBranding: true,
};
describe("SurveyLinkUsed", () => {
afterEach(() => {
cleanup();
});
test("renders with default values when singleUseMessage is null", () => {
render(<SurveyLinkUsed singleUseMessage={null} />);
render(<SurveyLinkUsed singleUseMessage={null} project={mockProject} />);
expect(screen.getByText("s.survey_already_answered_heading")).toBeInTheDocument();
expect(screen.getByText("s.survey_already_answered_subheading")).toBeInTheDocument();
@@ -31,17 +35,28 @@ describe("SurveyLinkUsed", () => {
subheading: "Custom Subheading",
} as any;
render(<SurveyLinkUsed singleUseMessage={singleUseMessage} />);
render(<SurveyLinkUsed singleUseMessage={singleUseMessage} project={mockProject} />);
expect(screen.getByText("Custom Heading")).toBeInTheDocument();
expect(screen.getByText("Custom Subheading")).toBeInTheDocument();
});
test("renders footer with link to Formbricks", () => {
render(<SurveyLinkUsed singleUseMessage={null} />);
test("renders footer with link to Formbricks when branding is enabled", () => {
render(<SurveyLinkUsed singleUseMessage={null} project={mockProject} />);
const link = document.querySelector('a[href="https://formbricks.com"]');
expect(link).toBeInTheDocument();
expect(vi.mocked(Image)).toHaveBeenCalled();
});
test("does not render footer when branding is disabled", () => {
const projectWithoutBranding = {
linkSurveyBranding: false,
};
render(<SurveyLinkUsed singleUseMessage={null} project={projectWithoutBranding} />);
const link = document.querySelector('a[href="https://formbricks.com"]');
expect(link).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import Image from "next/image";
@@ -9,27 +10,29 @@ import footerLogo from "../lib/footerlogo.svg";
interface SurveyLinkUsedProps {
singleUseMessage: TSurveySingleUse | null;
project?: Pick<Project, "linkSurveyBranding">;
}
export const SurveyLinkUsed = ({ singleUseMessage }: SurveyLinkUsedProps) => {
export const SurveyLinkUsed = ({ singleUseMessage, project }: SurveyLinkUsedProps) => {
const { t } = useTranslate();
const defaultHeading = t("s.survey_already_answered_heading");
const defaultSubheading = t("s.survey_already_answered_subheading");
return (
<div className="flex min-h-screen flex-col items-center justify-between bg-gradient-to-tr from-slate-200 to-slate-50 py-8 text-center">
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
<div className="my-auto flex flex-col items-center space-y-3 text-slate-300">
<CheckCircle2Icon className="h-20 w-20" />
<h1 className="text-4xl font-bold text-slate-800">{singleUseMessage?.heading ?? defaultHeading}</h1>
<p className="text-lg leading-10 text-slate-500">
{singleUseMessage?.subheading ?? defaultSubheading}
</p>
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
{(!project || project.linkSurveyBranding) && (
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
)}
</div>
);
};

View File

@@ -251,4 +251,33 @@ describe("renderSurvey", () => {
})
).rejects.toThrow("Project not found");
});
describe("Survey Inactive States", () => {
test("renders SurveyInactive with project when survey is paused", async () => {
const survey: TSurvey = { ...mockSurvey, status: "paused" };
const project = { id: "project-123", linkSurveyBranding: true };
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(project as any);
const result = await renderSurvey({
survey: survey,
searchParams: {},
isPreview: false,
});
expect(result).toBeDefined();
});
test("renders SurveyInactive without project when project is not found", async () => {
const survey: TSurvey = { ...mockSurvey, status: "paused" };
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
const result = await renderSurvey({
survey: survey,
searchParams: {},
isPreview: false,
});
expect(result).toBeDefined();
});
});
});

View File

@@ -60,10 +60,12 @@ export const renderSurvey = async ({
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
project={project || undefined}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -50,12 +51,19 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
// When token is invalid, we don't have survey data to get project branding settings
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId)();
if (existingResponse) {
const survey = await getSurvey(surveyId);
if (survey) {
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="response submitted" project={project || undefined} />;
}
return <SurveyInactive status="response submitted" />;
}

View File

@@ -47,9 +47,5 @@ describe("LinkSurveyNotFound", () => {
// Check the basic elements that are visible in the rendered output
expect(screen.getByText("Survey not found.")).toBeInTheDocument();
expect(screen.getByText("There is no survey with this ID.")).toBeInTheDocument();
expect(screen.getByTestId("mock-help-circle-icon")).toBeInTheDocument();
// Check the button exists
expect(screen.getByTestId("mock-button")).toBeInTheDocument();
});
});

View File

@@ -1,26 +1,12 @@
import { Button } from "@/modules/ui/components/button";
import { HelpCircleIcon } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import Link from "next/link";
import footerLogo from "./lib/footerlogo.svg";
export const LinkSurveyNotFound = () => {
return (
<div className="flex h-full flex-col items-center justify-between bg-gradient-to-br from-slate-200 to-slate-50 py-8 text-center">
<div></div>
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-slate-200 to-slate-50 py-8 text-center">
<div className="flex flex-col items-center space-y-3 text-slate-300">
<HelpCircleIcon className="h-20 w-20" />,
<HelpCircleIcon className="h-20 w-20" />
<h1 className="text-4xl font-bold text-slate-800">Survey not found.</h1>
<p className="text-lg leading-10 text-slate-500">There is no survey with this ID.</p>
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">Create your own</Link>
</Button>
</div>
<div>
<Link href="https://formbricks.com">
<Image src={footerLogo as StaticImport} alt="Brand logo" className="mx-auto w-40" />
</Link>
</div>
</div>
);

View File

@@ -1,14 +1,15 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId } from "@/modules/survey/link/lib/data";
import { getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponseData } from "@formbricks/types/responses";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { LinkSurveyPage, generateMetadata } from "./page";
@@ -26,22 +27,32 @@ vi.mock("@/modules/survey/lib/survey", () => ({
}));
vi.mock("@/modules/survey/link/components/survey-inactive", () => ({
SurveyInactive: vi.fn(() => <div data-testid="survey-inactive" />),
SurveyInactive: vi.fn(() => <div>Survey Inactive</div>),
}));
vi.mock("@/modules/survey/link/components/survey-renderer", () => ({
renderSurvey: vi.fn(() => <div data-testid="survey-renderer" />),
renderSurvey: vi.fn(() => <div>Render Survey</div>),
}));
vi.mock("@/modules/survey/link/lib/data", () => ({
getResponseBySingleUseId: vi.fn(() => vi.fn()),
getResponseBySingleUseId: vi.fn(),
getSurveyWithMetadata: vi.fn(),
}));
vi.mock("@/modules/survey/link/lib/project", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/link/metadata", () => ({
getMetadataForLinkSurvey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("LinkSurveyPage", () => {
afterEach(() => {
cleanup();
@@ -232,4 +243,169 @@ describe("LinkSurveyPage", () => {
})
);
});
test("should show 'link invalid' for single-use survey without suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ id: "proj-123" } as any);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: { id: "proj-123" },
});
});
test("should show 'link invalid' for encrypted single-use survey with invalid suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(validateSurveySingleUseId).mockReturnValue(undefined);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ id: "proj-123" } as any);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({ suId: "invalid-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: { id: "proj-123" },
});
});
test("should render survey for encrypted single-use survey with valid suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(validateSurveySingleUseId).mockReturnValue("valid-suid");
const mockResponseFn = vi.fn().mockResolvedValue({ id: "res-1" });
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({ suId: "encrypted-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "valid-suid",
singleUseResponse: { id: "res-1" },
})
);
});
test("should render survey for non-encrypted single-use survey", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
const mockResponseFn = vi.fn().mockResolvedValue({ id: "res-1" });
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({ suId: "plain-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "plain-suid",
singleUseResponse: { id: "res-1" },
})
);
});
test("should render survey with undefined response when getResponseBySingleUseId fails", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
const mockResponseFn = vi.fn().mockRejectedValue(new Error("DB error"));
vi.mocked(getResponseBySingleUseId).mockReturnValue(mockResponseFn);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({ suId: "plain-suid" }),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(logger.error).toHaveBeenCalled();
expect(vi.mocked(renderSurvey).mock.calls[0][0]).toEqual(
expect.objectContaining({
singleUseId: "plain-suid",
singleUseResponse: undefined,
})
);
});
test("should handle missing project for single-use survey without suId", async () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
vi.mocked(getSurveyWithMetadata).mockResolvedValue(singleUseSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
const Page = await LinkSurveyPage(props);
render(Page);
expect(vi.mocked(SurveyInactive).mock.calls[0][0]).toEqual({
status: "link invalid",
project: undefined,
});
});
test("LinkSurveyPage calls notFound when getSurveyWithMetadata throws an error", async () => {
const databaseError = new Error("Database connection failed");
vi.mocked(getSurveyWithMetadata).mockRejectedValue(databaseError);
const props = {
params: Promise.resolve({ surveyId: "survey123" }),
searchParams: Promise.resolve({}),
};
await LinkSurveyPage(props);
expect(getSurveyWithMetadata).toHaveBeenCalledWith("survey123");
expect(logger.error).toHaveBeenCalledWith(databaseError, "Error fetching survey");
expect(notFound).toHaveBeenCalled();
});
});
describe("generateMetadata", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should call notFound for invalid surveyId", async () => {
const props = {
params: Promise.resolve({ surveyId: "invalid-id" }),
searchParams: Promise.resolve({}),
};
await generateMetadata(props);
expect(notFound).toHaveBeenCalled();
});
test("should call getMetadataForLinkSurvey for valid surveyId", async () => {
vi.mocked(getMetadataForLinkSurvey).mockResolvedValue({ title: "Test Survey" });
const props = {
params: Promise.resolve({ surveyId: "survey-123" }),
searchParams: Promise.resolve({}),
};
const metadata = await generateMetadata(props);
expect(getMetadataForLinkSurvey).toHaveBeenCalledWith("survey-123");
expect(metadata).toEqual({ title: "Test Survey" });
});
});

View File

@@ -2,11 +2,13 @@ import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
interface LinkSurveyPageProps {
params: Promise<{
@@ -42,7 +44,14 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
const isPreview = searchParams.preview === "true";
// Use optimized survey data fetcher (includes all necessary data)
const survey = await getSurveyWithMetadata(params.surveyId);
let survey: TSurvey | null = null;
try {
survey = await getSurveyWithMetadata(params.surveyId);
} catch (error) {
logger.error(error, "Error fetching survey");
return notFound();
}
const suId = searchParams.suId;
const isSingleUseSurvey = survey?.singleUse?.enabled;
@@ -53,7 +62,8 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (isSingleUseSurvey) {
// check if the single use id is present for single use surveys
if (!suId) {
return <SurveyInactive status="link invalid" />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
// if encryption is enabled, validate the single use id
@@ -61,7 +71,8 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (isSingleUseSurveyEncrypted) {
validatedSingleUseId = validateSurveySingleUseId(suId);
if (!validatedSingleUseId) {
return <SurveyInactive status="link invalid" />;
const project = await getProjectByEnvironmentId(survey.environmentId);
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
}
// if encryption is disabled, use the suId as is

View File

@@ -31,7 +31,7 @@ vi.mock("@/modules/ui/components/checkbox", () => ({
id={id}
data-testid={id}
name={props.name}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
onChange={(e) => {
// Call onCheckedChange with the checked state
onCheckedChange && onCheckedChange(e.target.checked);

View File

@@ -105,11 +105,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
field.onChange([...field.value, environment.id]);
}
}}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
id={environment.id}
/>
<Label htmlFor={environment.id}>
<p className="text-sm font-medium capitalize text-slate-900">
<p className="text-sm font-medium text-slate-900 capitalize">
{environment.type}
</p>
</Label>
@@ -128,8 +128,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
);
})}
</div>
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pb-4 pr-4">
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
<div className="flex w-full justify-end pr-4 pb-4">
<Button type="button" onClick={onCancel} variant="ghost">
{t("common.cancel")}
</Button>

View File

@@ -72,7 +72,7 @@ export const SurveyCard = ({
</div>
<div
className={cn(
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
"col-span-1 flex w-fit items-center gap-2 rounded-full py-1 pr-2 pl-1 text-sm whitespace-nowrap text-slate-800",
surveyStatusLabel === "Scheduled" && "bg-slate-200",
surveyStatusLabel === "In Progress" && "bg-emerald-50",
surveyStatusLabel === "Completed" && "bg-slate-200",
@@ -81,23 +81,23 @@ export const SurveyCard = ({
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
{survey.responseCount}
</div>
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
{convertDateString(survey.createdAt.toString())}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
{survey.creator ? survey.creator.name : "-"}
</div>
</div>
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
<button className="absolute top-3.5 right-3" onClick={(e) => e.stopPropagation()}>
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}

View File

@@ -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<typeof useSignOut>;
describe("ClientLogout", () => {
test("calls signOut on render", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseSignOut.mockReturnValue({
signOut: mockSignOut,
});
});
test("calls signOut with correct parameters on render", () => {
render(<ClientLogout />);
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(<ClientLogout />);
expect(mockUseSignOut).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "forced_logout",
redirectUrl: "/auth/login",
redirect: false,
callbackUrl: "/auth/login",
});
});
test("removes environment ID from localStorage", () => {
render(<ClientLogout />);
expect(mockRemoveItem).toHaveBeenCalledWith("formbricks-environment-id");
});
test("renders null", () => {

View File

@@ -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;
};

View File

@@ -132,10 +132,9 @@ externalSecret:
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: {{ requiredEnv "FORMBRICKS_INGRESS_CERT_ARN" }}
alb.ingress.kubernetes.io/group.name: formbricks-stage
alb.ingress.kubernetes.io/group.name: internal
alb.ingress.kubernetes.io/healthcheck-path: /health
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-Res-2021-06
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip