Compare commits

..

1 Commits

Author SHA1 Message Date
Piyush Gupta
5fd1cd65d1 chore: adds debug logs 2025-06-05 09:57:02 +05:30
194 changed files with 4556 additions and 8819 deletions

View File

@@ -3,5 +3,4 @@ description: Whenever the user asks to write or update a test file for .tsx or .
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)

View File

@@ -190,7 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -216,8 +216,3 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -45,16 +45,6 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
valkey:
image: valkey/valkey:8.1.1
ports:
- 6379:6379
options: >-
--entrypoint "valkey-server"
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -86,8 +86,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({

View File

@@ -1,33 +1,15 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
@@ -88,12 +70,6 @@ describe("LandingSidebar component", () => {
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
});
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
});
});

View File

@@ -3,7 +3,6 @@
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -21,6 +20,7 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -44,7 +44,6 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
@@ -124,13 +123,7 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
});
await signOut({ callbackUrl: "/auth/login" });
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -89,8 +89,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");

View File

@@ -98,8 +98,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({

View File

@@ -35,8 +35,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -34,8 +34,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies

View File

@@ -26,8 +26,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
describe("Contact Page Re-export", () => {

View File

@@ -4,9 +4,7 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
@@ -22,69 +20,62 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient
.schema(ZCreateProjectAction)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
],
});
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
const organization = await getOrganization(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
if (!organization) {
throw new Error("Organization not found");
}
)
);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});

View File

@@ -3,10 +3,8 @@
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
@@ -16,80 +14,63 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
withAuditLogging(
"deleted",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
return await deleteActionClass(parsedInput.actionClassId);
}
)
);
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
await deleteActionClass(parsedInput.actionClassId);
});
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
withAuditLogging(
"updated",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,

View File

@@ -1,6 +1,6 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -10,17 +10,6 @@ import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
@@ -29,9 +18,6 @@ vi.mock("next/navigation", () => ({
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
@@ -217,9 +203,7 @@ describe("MainNavigation", () => {
});
test("renders user dropdown and handles logout", async () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -240,13 +224,7 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});

View File

@@ -6,7 +6,6 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -43,6 +42,7 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -90,7 +90,6 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -390,14 +389,8 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -2,15 +2,13 @@
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration";
@@ -22,79 +20,48 @@ const ZCreateOrUpdateIntegrationAction = z.object({
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
"integration",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(
parsedInput.environmentId,
parsedInput.integrationData
);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
});
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
export const deleteIntegrationAction = authenticatedActionClient
.schema(ZDeleteIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
return await deleteIntegration(parsedInput.integrationId);
});

View File

@@ -49,8 +49,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/integration/service");

View File

@@ -2,7 +2,7 @@
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -32,8 +32,6 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock child components

View File

@@ -2,7 +2,7 @@
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("AppConnectionPage Re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("GeneralSettingsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("LanguagesPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TagsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("ProjectTeams re-export", () => {

View File

@@ -41,8 +41,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,9 +1,7 @@
"use server";
import { getUser, updateUser } from "@/lib/user/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
@@ -13,25 +11,8 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
.action(async ({ ctx, parsedInput }) => {
await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
});

View File

@@ -7,12 +7,10 @@ import {
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -29,136 +27,93 @@ const limiter = rateLimit({
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
}
async function handleEmailUpdate({
ctx,
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const isEmailUnique = await getIsEmailUnique(inputEmail);
if (!isEmailUnique) return payload;
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
.action(async ({ parsedInput, ctx }) => {
const inputEmail = parsedInput.email?.trim().toLowerCase();
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
let payload: TUserUpdateInput = {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
)
);
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
}
}
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const updateAvatarAction = authenticatedActionClient
.schema(ZUpdateAvatarAction)
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
});
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
export const removeAvatarAction = authenticatedActionClient
.schema(ZRemoveAvatarAction)
.action(async ({ parsedInput, ctx }) => {
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
)
);
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
return await updateUser(ctx.user.id, { imageUrl: null });
});

View File

@@ -3,7 +3,6 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -17,6 +16,8 @@ import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -38,6 +39,7 @@ export const EditProfileDetailsForm = ({
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({
defaultValues: {
@@ -51,7 +53,6 @@ export const EditProfileDetailsForm = ({
const { isSubmitting, isDirty } = form.formState;
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
@@ -85,12 +86,8 @@ export const EditProfileDetailsForm = ({
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOutWithAudit({
reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
});
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
}
} else {

View File

@@ -1,10 +1,8 @@
"use server";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -18,65 +16,43 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient
.schema(ZDeleteOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
return await deleteOrganization(parsedInput.organizationId);
});

View File

@@ -30,8 +30,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TeamsPage re-export", () => {

View File

@@ -2,7 +2,7 @@
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache";
import { z } from "zod";

View File

@@ -45,8 +45,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");

View File

@@ -3,10 +3,8 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
@@ -65,55 +63,37 @@ const ZGenerateResultShareUrlAction = z.object({
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
await updateSurvey({ ...survey, resultShareKey });
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
return resultShareKey;
});
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
@@ -152,50 +132,30 @@ const ZDeleteResultShareUrlAction = z.object({
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
return await updateSurvey({ ...survey, resultShareKey: null });
});
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -41,36 +41,6 @@ const mockSurveyWeb = {
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
@@ -204,32 +174,20 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {

View File

@@ -1,7 +1,6 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -63,20 +62,6 @@ export const ShareEmbedSurvey = ({
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, surveyDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, surveyDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
@@ -101,7 +86,7 @@ export const ShareEmbedSurvey = ({
};
const handleInitialPageButton = () => {
setShowView("start");
setOpen(false);
};
return (

View File

@@ -7,20 +7,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
}),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -44,9 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
}));
// Create a spy for refreshSingleUseId so we can override it in tests

View File

@@ -68,7 +68,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -5,10 +5,8 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -16,7 +14,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { ZSurvey } from "@formbricks/types/surveys/types";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
@@ -104,54 +102,39 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
)
);
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});

View File

@@ -39,8 +39,6 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

View File

@@ -7,8 +7,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -181,33 +179,10 @@ export const POST = async (request: Request) => {
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
await updateSurvey({
...survey,
status: "completed",
});
}
// Await webhook and email promises with allSettled to prevent early rejection

View File

@@ -1,140 +1,8 @@
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { authOptions } from "@/modules/auth/lib/authOptions";
import NextAuth from "next-auth";
import { logger } from "@formbricks/logger";
export const fetchCache = "force-no-store";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
const authOptions = {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.session) {
result = await baseAuthOptions.callbacks.session(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("Session callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
result = await baseAuthOptions.callbacks.signIn({
user,
account,
profile,
email,
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
logger.withContext({ eventId, err }).error("User sign-in failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
return NextAuth(authOptions)(req, ctx);
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/well-known/cert/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/ee/auth/saml/api/well-known/sp-metadata/route";
export { GET };

View File

@@ -1,7 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -45,104 +44,63 @@ export const GET = async (
}
};
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
export const PUT = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
let actionClassUpdate;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
if (updatedActionClass) {
auditLog.newObject = updatedActionClass;
return {
response: responses.successResponse(updatedActionClass),
};
}
return {
response: responses.internalServerErrorResponse("Some error occurred while updating action"),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
"updated",
"actionClass"
);
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
response: responses.successResponse(deletedActionClass),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
);
if (updatedActionClass) {
return responses.successResponse(updatedActionClass);
}
},
"deleted",
"actionClass"
);
return responses.internalServerErrorResponse("Some error ocured while updating action");
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
const deletedActionClass = await deleteActionClass(params.actionClassId);
return responses.successResponse(deletedActionClass);
} catch (error) {
return handleErrorResponse(error);
}
};

View File

@@ -1,7 +1,6 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -29,62 +28,41 @@ export const GET = async (request: Request) => {
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let actionClassInput;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
let actionClassInput;
try {
actionClassInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;
return {
response: responses.successResponse(actionClass),
};
actionClassInput = await request.json();
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
},
"created",
"actionClass"
);
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};

View File

@@ -1,7 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -49,101 +48,58 @@ export const GET = async (
}
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
export const DELETE = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
const deletedResponse = await deleteResponse(params.responseId);
return {
response: responses.successResponse(deletedResponse),
};
responseUpdate = await request.json();
} catch (error) {
return {
response: handleErrorResponse(error),
};
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
},
"deleted",
"response"
);
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
let responseUpdate;
try {
responseUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updated = await updateResponse(params.responseId, inputValidation.data);
auditLog.newObject = updated;
return {
response: responses.successResponse(updated),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
},
"updated",
"response"
);
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
}
return responses.successResponse(await updateResponse(params.responseId, inputValidation.data));
} catch (error) {
return handleErrorResponse(error);
}
};

View File

@@ -1,7 +1,6 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -92,78 +91,46 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) {
return {
response: inputResult.error,
};
}
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) {
return {
response: surveyResult.error,
};
}
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
try {
const response = await createResponse(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
},
"created",
"response"
);
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
try {
const response = await createResponse(responseInput);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};

View File

@@ -3,7 +3,6 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -43,121 +42,64 @@ export const GET = async (
}
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
export const DELETE = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) return result.error;
const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey);
} catch (error) {
return handleErrorResponse(error);
}
};
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
export const PUT = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const deletedSurvey = await deleteSurvey(params.surveyId);
return {
response: responses.successResponse(deletedSurvey),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
},
"deleted",
"survey"
);
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
let surveyUpdate;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
try {
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
auditLog.newObject = updatedSurvey;
return {
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
surveyUpdate = await request.json();
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
},
"updated",
"survey"
);
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) return featureCheckResult;
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
} catch (error) {
return handleErrorResponse(error);
}
};

View File

@@ -2,7 +2,6 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -34,78 +33,50 @@ export const GET = async (request: Request) => {
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let surveyInput;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
let surveyInput;
try {
surveyInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
return {
response: responses.successResponse(survey),
};
surveyInput = await request.json();
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
},
"created",
"survey"
);
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) return featureCheckResult;
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};

View File

@@ -1,7 +1,6 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
@@ -29,54 +28,33 @@ export const GET = async (request: Request, props: { params: Promise<{ webhookId
return responses.successResponse(webhook);
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ webhookId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.webhookId;
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return {
response: responses.notAuthenticatedResponse(),
};
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return {
response: responses.unauthorizedResponse(),
};
}
export const DELETE = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
auditLog.oldObject = webhook;
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return responses.unauthorizedResponse();
}
// delete webhook from database
try {
const deletedWebhook = await deleteWebhook(params.webhookId);
return {
response: responses.successResponse(deletedWebhook),
};
} catch (e) {
auditLog.status = "failure";
logger.error({ error: e, url: request.url }, "Error deleting webhook");
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
},
"deleted",
"webhook"
);
// delete webhook from database
try {
const webhook = await deleteWebhook(params.webhookId);
return responses.successResponse(webhook);
} catch (e) {
logger.error({ error: e, url: request.url }, "Error deleting webhook");
return responses.notFoundResponse("Webhook", params.webhookId);
}
};

View File

@@ -3,7 +3,6 @@ import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
@@ -26,65 +25,43 @@ export const GET = async (request: Request) => {
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
export const POST = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return responses.badRequestResponse("Environment ID is required");
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// add webhook to database
try {
const webhook = await createWebhook(inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
auditLog.organizationId = authentication.organizationId;
auditLog.userId = authentication.apiKeyId;
const webhookInput = await request.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
}
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return {
response: responses.badRequestResponse("Environment ID is required"),
};
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
try {
const webhook = await createWebhook(inputValidation.data);
auditLog.targetId = webhook.id;
auditLog.newObject = webhook;
return {
response: responses.successResponse(webhook),
};
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.internalServerErrorResponse(error.message),
};
}
throw error;
}
},
"created",
"webhook"
);
throw error;
}
};

View File

@@ -1,277 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { responses } from "./response";
import { ApiAuditLog } from "./with-api-logging";
// Mocks
// This top-level mock is crucial for the SUT (withApiLogging.ts)
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
warn: mockContextualLoggerWarn,
info: mockContextualLoggerInfo,
}));
return {
logger: {
withContext: mockWithContextInstance,
// These are for direct calls like logger.error(), logger.warn()
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
};
});
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
return {
method,
url,
headers: {
get: (key: string) => headers.get(key),
},
} as unknown as Request;
}
// Minimal valid ApiAuditLog
const baseAudit: ApiAuditLog = {
action: "created",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
status: "failure",
userType: "api",
};
describe("withApiLogging", () => {
beforeEach(() => {
vi.resetModules(); // Reset SUT and other potentially cached modules
// vi.doMock for constants if a specific test needs to override it
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
// or are already in place due to vi.mock hoisting.
// Restore the mock for constants to its default for most tests
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
});
test("logs and audits on error response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.internalServerErrorResponse("fail"),
};
});
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "abc-123",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.badRequestResponse("bad req"),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
throw new Error("fail!");
});
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
code: "internal_server_error",
message: "An unexpected error occurred.",
details: {},
});
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "err-1",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
});
test("does not log/audit on success response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.successResponse({ ok: true }),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).not.toHaveBeenCalled();
expect(mockContextualLoggerError).not.toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "success",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
// For this specific test, we override the AUDIT_LOG_ENABLED constant
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { withApiLogging } = await import("./with-api-logging");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
audit: { ...baseAudit },
});
const req = createMockRequest();
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
});

View File

@@ -1,103 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
export type ApiAuditLog = Parameters<typeof queueAuditEvent>[0];
/**
* withApiLogging wraps an V1 API handler to provide unified error/audit/system logging.
* - Handler must return { response }.
* - If not a successResponse, calls audit log, system log, and Sentry as needed.
* - System and Sentry logs are always called for non-success responses.
*/
export const withApiLogging = <TResult extends { response: Response }>(
handler: (req: Request, props?: any, auditLog?: ApiAuditLog) => Promise<TResult>,
action: TAuditAction,
targetType: TAuditTarget
) => {
return async function (req: Request, props: any): Promise<Response> {
const auditLog = buildAuditLogBaseObject(action, targetType, req.url);
let result: { response: Response };
let error: any = undefined;
try {
result = await handler(req, props, auditLog);
} catch (err) {
error = err;
result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
};
}
const res = result.response;
// Try to parse the response as JSON to check if it's a success or error
let isSuccess = false;
let parsed: any = undefined;
try {
parsed = await res.clone().json();
isSuccess = parsed && typeof parsed === "object" && "data" in parsed;
} catch {
isSuccess = false;
}
const correlationId = req.headers.get("x-request-id") ?? "";
if (!isSuccess) {
if (auditLog) {
auditLog.eventId = correlationId;
}
// System log
const logContext: any = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
};
if (error) {
logContext.error = error;
}
logger.withContext(logContext).error("API Error Details");
// Sentry log
if (SENTRY_DSN && IS_PRODUCTION && res.status === 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
error,
correlationId,
},
});
}
} else {
auditLog.status = "success";
}
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
}
return res;
};
};
export const buildAuditLogBaseObject = (
action: TAuditAction,
targetType: TAuditTarget,
apiUrl: string
): ApiAuditLog => {
return {
action,
targetType,
userId: UNKNOWN_DATA,
targetId: UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
};
};

View File

@@ -4,8 +4,6 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
@@ -14,31 +12,24 @@ const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action(
withAuditLogging(
"created",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
export const createOrganizationAction = authenticatedActionClient
.schema(ZCreateOrganizationAction)
.action(async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
)
);
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
return newOrganization;
});

View File

@@ -3,13 +3,9 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZStorageRetrievalParams } from "@formbricks/types/storage";
import { getFile } from "./lib/get-file";
@@ -61,161 +57,46 @@ export const GET = async (
};
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
_: NextRequest,
props: { params: Promise<{ fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const getOrgId = async (environmentId: string): Promise<string> => {
try {
return await getOrganizationIdFromEnvironmentId(environmentId);
} catch (error) {
logger.error("Failed to get organization ID for environment", { error });
return UNKNOWN_DATA;
}
};
const logFileDeletion = async ({
accessType,
userId,
status = "failure",
failureReason,
oldObject,
}: {
accessType?: string;
userId?: string;
status?: TAuditStatus;
failureReason?: string;
oldObject?: Record<string, unknown>;
}) => {
try {
const organizationId = await getOrgId(environmentId);
await queueAuditEvent({
action: "deleted",
targetType: "file",
userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too
userType: "user",
targetId: `${environmentId}:${accessType}`, // Generic target identifier
organizationId,
status,
newObject: {
environmentId,
accessType,
...(failureReason && { failureReason }),
},
oldObject,
apiUrl: request.url,
});
} catch (auditError) {
logger.error("Failed to log file deletion audit event:", auditError);
}
};
// Validation
if (!params.fileName) {
await logFileDeletion({
failureReason: "fileName parameter missing",
});
return responses.badRequestResponse("Fields are missing or incorrectly formatted", {
fileName: "fileName is required",
});
}
const { environmentId, accessType, fileName } = params;
// Security check: If fileName contains the same properties from the route, ensure they match
// This is to prevent a user from deleting a file from a different environment
const [fileEnvironmentId, fileAccessType, file] = fileName.split("/");
if (fileEnvironmentId !== environmentId) {
await logFileDeletion({
failureReason: "Environment ID mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Environment ID mismatch", {
message: "The environment ID in the fileName does not match the route environment ID",
});
}
if (fileAccessType !== accessType) {
await logFileDeletion({
failureReason: "Access type mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Access type mismatch", {
message: "The access type in the fileName does not match the route access type",
});
}
const [environmentId, accessType, file] = params.fileName.split("/");
const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType });
if (!paramValidation.success) {
await logFileDeletion({
failureReason: "Parameter validation failed",
accessType,
});
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(paramValidation.error),
true
);
}
// check if user is authenticated
const {
environmentId: validEnvId,
accessType: validAccessType,
fileName: validFileName,
} = paramValidation.data;
// Authentication
const session = await getServerSession(authOptions);
if (!session?.user) {
await logFileDeletion({
failureReason: "User not authenticated",
accessType: validAccessType,
});
return responses.notAuthenticatedResponse();
}
// Authorization
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId);
// check if the user has access to the environment
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType: validAccessType,
userId: session.user.id,
});
return responses.unauthorizedResponse();
}
try {
const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName);
const isSuccess = deleteResult.status === 200;
let failureReason = "File deletion failed";
if (!isSuccess) {
try {
const responseBody = await deleteResult.json();
failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too
} catch (error) {
logger.error("Failed to parse file delete error response body", { error });
}
}
await logFileDeletion({
status: isSuccess ? "success" : "failure",
failureReason: isSuccess ? undefined : failureReason,
accessType: validAccessType,
userId: session.user.id,
});
return deleteResult;
} catch (error) {
await logFileDeletion({
failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion",
accessType: validAccessType,
userId: session.user.id,
});
throw error;
}
return await handleDeleteFile(
paramValidation.data.environmentId,
paramValidation.data.accessType,
paramValidation.data.fileName
);
};

View File

@@ -53,11 +53,12 @@ describe("Organization Access", () => {
test("hasOrganizationAccess should return true when user has membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
@@ -73,11 +74,12 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return true for manager role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -86,11 +88,12 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return true for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -99,11 +102,12 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return false for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -112,11 +116,12 @@ describe("Organization Access", () => {
test("isOwner should return true only for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
@@ -125,11 +130,12 @@ describe("Organization Access", () => {
test("isOwner should return false for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
@@ -147,11 +153,12 @@ describe("Organization Authority", () => {
test("hasOrganizationAuthority should return true for manager", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
@@ -166,11 +173,12 @@ describe("Organization Authority", () => {
test("hasOrganizationAuthority should throw for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
@@ -178,11 +186,12 @@ describe("Organization Authority", () => {
test("hasOrganizationOwnership should return true for owner", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
@@ -197,11 +206,12 @@ describe("Organization Authority", () => {
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
accepted: true,
deprecatedRole: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);

View File

@@ -7,6 +7,7 @@ export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
@@ -281,11 +282,4 @@ export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
export const AUDIT_LOG_ENABLED =
env.AUDIT_LOG_ENABLED === "1" &&
env.REDIS_URL &&
env.REDIS_URL !== "" &&
env.ENCRYPTION_KEY &&
env.ENCRYPTION_KEY !== ""; // The audit log requires Redis to be configured
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;

View File

@@ -105,12 +105,7 @@ export const env = createEnv({
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
AUDIT_LOG_ENABLED: z.enum(["1", "0"]).optional(),
AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(),
SESSION_MAX_AGE: z
.string()
.transform((val) => parseInt(val))
.optional(),
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
},
/*
@@ -206,8 +201,6 @@ export const env = createEnv({
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
},
});

View File

@@ -0,0 +1,100 @@
import { getMembershipRole } from "@/lib/membership/hooks/actions";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
export const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {
_errors: [issue.message],
};
return acc;
}, {}),
};
};
export type TAccess<T extends z.ZodRawShape> =
| {
type: "organization";
schema?: z.ZodObject<T>;
data?: z.ZodObject<T>["_output"];
roles: TOrganizationRole[];
}
| {
type: "projectTeam";
minPermission?: TTeamPermission;
projectId: string;
}
| {
type: "team";
minPermission?: TTeamRole;
teamId: string;
};
const teamPermissionWeight = {
read: 1,
readWrite: 2,
manage: 3,
};
const teamRoleWeight = {
contributor: 1,
admin: 2,
};
export const checkAuthorizationUpdated = async <T extends z.ZodRawShape>({
userId,
organizationId,
access,
}: {
userId: string;
organizationId: string;
access: TAccess<T>[];
}) => {
const role = await getMembershipRole(userId, organizationId);
for (const accessItem of access) {
if (accessItem.type === "organization") {
if (accessItem.schema) {
const resultSchema = accessItem.schema.strict();
const parsedResult = resultSchema.safeParse(accessItem.data);
if (!parsedResult.success) {
// @ts-expect-error -- TODO: match dynamic next-safe-action types
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
}
}
if (accessItem.roles.includes(role)) {
return true;
}
} else {
if (accessItem.type === "projectTeam") {
const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId);
if (
!projectPermission ||
(accessItem.minPermission !== undefined &&
teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission])
) {
continue;
}
} else {
const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId);
if (
!teamRole ||
(accessItem.minPermission !== undefined &&
teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission])
) {
continue;
}
}
return true;
}
}
throw new AuthorizationError("Not authorized");
};

View File

@@ -1,12 +1,8 @@
import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { getServerSession } from "next-auth";
import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
import {
AuthenticationError,
@@ -17,16 +13,10 @@ import {
TooManyRequestsError,
UnknownError,
} from "@formbricks/types/errors";
import { ActionClientCtx } from "./types/context";
export const actionClient = createSafeActionClient({
handleServerError(e, utils) {
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
Sentry.captureException(e, {
extra: {
eventId,
},
});
handleServerError(e) {
Sentry.captureException(e);
if (
e instanceof ResourceNotFoundError ||
@@ -41,28 +31,12 @@ export const actionClient = createSafeActionClient({
}
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
logger.withContext({ eventId }).error(e, "SERVER ERROR");
logger.error(e, "SERVER ERROR");
return DEFAULT_SERVER_ERROR_MESSAGE;
},
}).use(async ({ next }) => {
// Create a unique event id
const eventId = uuidv4();
const ctx: ActionClientCtx = { auditLoggingCtx: { eventId, ipAddress: UNKNOWN_DATA } };
if (AUDIT_LOG_ENABLED && AUDIT_LOG_GET_USER_IP) {
try {
const ipAddress = await getClientIpFromHeaders();
ctx.auditLoggingCtx.ipAddress = ipAddress;
} catch (err) {
// Non-fatal we keep UNKNOWN_DATA
logger.warn({ err }, "Failed to resolve client IP for audit logging");
}
}
return next({ ctx });
});
export const authenticatedActionClient = actionClient.use(async ({ ctx, next }) => {
export const authenticatedActionClient = actionClient.use(async ({ next }) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
@@ -75,5 +49,5 @@ export const authenticatedActionClient = actionClient.use(async ({ ctx, next })
throw new AuthorizationError("User not found");
}
return next({ ctx: { ...ctx, user } });
return next({ ctx: { user } });
});

View File

@@ -1,120 +0,0 @@
import { getMembershipRole } from "@/lib/membership/hooks/actions";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { returnValidationErrors } from "next-safe-action";
import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
export const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {
_errors: [issue.message],
};
return acc;
}, {}),
};
};
export type TAccess<T extends z.ZodRawShape> =
| {
type: "organization";
schema?: z.ZodObject<T>;
data?: z.ZodObject<T>["_output"];
roles: TOrganizationRole[];
}
| {
type: "projectTeam";
minPermission?: TTeamPermission;
projectId: string;
}
| {
type: "team";
minPermission?: TTeamRole;
teamId: string;
};
const teamPermissionWeight = {
read: 1,
readWrite: 2,
manage: 3,
};
const teamRoleWeight = {
contributor: 1,
admin: 2,
};
const checkOrganizationAccess = <T extends z.ZodRawShape>(
accessItem: TAccess<T>,
role: TOrganizationRole
) => {
if (accessItem.type !== "organization") return false;
if (accessItem.schema) {
const resultSchema = accessItem.schema.strict();
const parsedResult = resultSchema.safeParse(accessItem.data);
if (!parsedResult.success) {
// @ts-expect-error -- match dynamic next-safe-action types
return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues));
}
}
return accessItem.roles.includes(role);
};
const checkProjectTeamAccess = async (accessItem: any, userId: string) => {
if (accessItem.type !== "projectTeam") return false;
const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId);
if (!projectPermission) return false;
if (
accessItem.minPermission !== undefined &&
teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission]
) {
return false;
}
return true;
};
const checkTeamAccess = async (accessItem: any, userId: string) => {
if (accessItem.type !== "team") return false;
const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId);
if (!teamRole) return false;
if (
accessItem.minPermission !== undefined &&
teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission]
) {
return false;
}
return true;
};
export const checkAuthorizationUpdated = async <T extends z.ZodRawShape>({
userId,
organizationId,
access,
}: {
userId: string;
organizationId: string;
access: TAccess<T>[];
}) => {
const role = await getMembershipRole(userId, organizationId);
for (const accessItem of access) {
if (accessItem.type === "organization") {
const orgResult = checkOrganizationAccess(accessItem, role);
if (orgResult === true) return true;
if (orgResult) return orgResult; // validation error
}
if (accessItem.type === "projectTeam" && (await checkProjectTeamAccess(accessItem, userId))) {
return true;
}
if (accessItem.type === "team" && (await checkTeamAccess(accessItem, userId))) {
return true;
}
}
throw new AuthorizationError("Not authorized");
};

View File

@@ -1,34 +0,0 @@
import { TUser } from "@formbricks/types/user";
export type AuditLoggingCtx = {
organizationId?: string;
ipAddress: string;
segmentId?: string;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
eventId?: string;
surveyId?: string;
tagId?: string;
webhookId?: string;
userId?: string;
projectId?: string;
languageId?: string;
inviteId?: string;
membershipId?: string;
actionClassId?: string;
contactId?: string;
apiKeyId?: string;
responseId?: string;
responseNoteId?: string;
teamId?: string;
integrationId?: string;
};
export type ActionClientCtx = {
auditLoggingCtx: AuditLoggingCtx;
user?: TUser;
};
export type AuthenticatedActionClientCtx = ActionClientCtx & {
user: TUser;
};

View File

@@ -1,82 +0,0 @@
import * as nextHeaders from "next/headers";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getClientIpFromHeaders } from "./client-ip";
// Mock next/headers
declare module "next/headers" {
export function headers(): any;
}
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
const mockHeaders = (headerMap: Record<string, string | undefined>) => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: (key: string) => headerMap[key.toLowerCase()] ?? undefined,
});
};
describe("getClientIpFromHeaders", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns cf-connecting-ip if present", async () => {
mockHeaders({ "cf-connecting-ip": "1.2.3.4" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("1.2.3.4");
});
test("returns first x-forwarded-for if cf-connecting-ip is missing", async () => {
mockHeaders({ "x-forwarded-for": "5.6.7.8, 9.10.11.12" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("5.6.7.8");
});
test("returns x-real-ip if cf-connecting-ip and x-forwarded-for are missing", async () => {
mockHeaders({ "x-real-ip": "13.14.15.16" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("13.14.15.16");
});
test("returns ::1 if no headers are present", async () => {
mockHeaders({});
const ip = await getClientIpFromHeaders();
expect(ip).toBe("::1");
});
test("trims whitespace in x-forwarded-for", async () => {
mockHeaders({ "x-forwarded-for": " 21.22.23.24 , 25.26.27.28" });
const ip = await getClientIpFromHeaders();
expect(ip).toBe("21.22.23.24");
});
test("getClientIpFromHeaders should return the value of the cf-connecting-ip header when it is present", async () => {
const testIp = "123.123.123.123";
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockImplementation((headerName: string) => {
if (headerName === "cf-connecting-ip") {
return testIp;
}
return null;
}),
} as any);
const result = await getClientIpFromHeaders();
expect(result).toBe(testIp);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("getClientIpFromHeaders should handle errors when headers() throws an exception", async () => {
vi.mocked(nextHeaders.headers).mockImplementation(() => {
throw new Error("Failed to get headers");
});
const result = await getClientIpFromHeaders();
expect(result).toBe("::1");
});
});

View File

@@ -1,22 +0,0 @@
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
export async function getClientIpFromHeaders(): Promise<string> {
let headersList: Headers;
try {
headersList = await headers();
} catch (e) {
logger.error(e, "Failed to get headers in getClientIpFromHeaders");
return "::1";
}
// Try common proxy headers first
const cfConnectingIp = headersList.get("cf-connecting-ip");
if (cfConnectingIp) return cfConnectingIp;
const xForwardedFor = headersList.get("x-forwarded-for");
if (xForwardedFor) return xForwardedFor.split(",")[0].trim();
// Fallback (may be undefined or localhost in dev)
return headersList.get("x-real-ip") || "::1"; // NOSONAR - We want to fallback when the result is ""
}

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "Produktmanager",
"product_market_fit_superhuman_question_3_choice_4": "People Manager",
"product_market_fit_superhuman_question_3_choice_5": "Softwareentwickler",
"product_market_fit_superhuman_question_3_headline": "Was ist deine Rolle?",
"product_market_fit_superhuman_question_3_subheader": "Bitte wähle eine der folgenden Optionen aus:",
"product_market_fit_superhuman_question_4_headline": "Wer würde am ehesten von $[projectName] profitieren?",
"product_market_fit_superhuman_question_5_headline": "Welchen Mehrwert ziehst Du aus $[projectName]?",

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "Product Manager",
"product_market_fit_superhuman_question_3_choice_4": "Product Owner",
"product_market_fit_superhuman_question_3_choice_5": "Software Engineer",
"product_market_fit_superhuman_question_3_headline": "What is your role?",
"product_market_fit_superhuman_question_3_subheader": "Please select one of the following options:",
"product_market_fit_superhuman_question_4_headline": "What type of people do you think would most benefit from $[projectName]?",
"product_market_fit_superhuman_question_5_headline": "What is the main benefit you receive from $[projectName]?",

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "Chef de produit",
"product_market_fit_superhuman_question_3_choice_4": "Propriétaire de produit",
"product_market_fit_superhuman_question_3_choice_5": "Ingénieur logiciel",
"product_market_fit_superhuman_question_3_headline": "Quel est votre rôle ?",
"product_market_fit_superhuman_question_3_subheader": "Veuillez sélectionner l'une des options suivantes :",
"product_market_fit_superhuman_question_4_headline": "Quel type de personnes pensez-vous bénéficierait le plus de $[projectName] ?",
"product_market_fit_superhuman_question_5_headline": "Quel est le principal avantage que vous tirez de $[projectName] ?",

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "Gerente de Produto",
"product_market_fit_superhuman_question_3_choice_4": "Dono do Produto",
"product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software",
"product_market_fit_superhuman_question_3_headline": "Qual é a sua função?",
"product_market_fit_superhuman_question_3_subheader": "Por favor, escolha uma das opções a seguir:",
"product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas você acha que mais se beneficiariam do $[projectName]?",
"product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que você recebe do $[projectName]?",

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "Gestor de Produto",
"product_market_fit_superhuman_question_3_choice_4": "Proprietário do Produto",
"product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software",
"product_market_fit_superhuman_question_3_headline": "Qual é o seu papel?",
"product_market_fit_superhuman_question_3_subheader": "Por favor, selecione uma das seguintes opções:",
"product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas acha que mais beneficiariam de $[projectName]?",
"product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que recebe de $[projectName]?",

View File

@@ -2629,7 +2629,6 @@
"product_market_fit_superhuman_question_3_choice_3": "產品經理",
"product_market_fit_superhuman_question_3_choice_4": "產品負責人",
"product_market_fit_superhuman_question_3_choice_5": "軟體工程師",
"product_market_fit_superhuman_question_3_headline": "您的角色是什麼?",
"product_market_fit_superhuman_question_3_subheader": "請選取以下其中一個選項:",
"product_market_fit_superhuman_question_4_headline": "您認為哪些類型的人最能從 {projectName} 中受益?",
"product_market_fit_superhuman_question_5_headline": "您從 {projectName} 獲得的主要好處是什麼?",

View File

@@ -18,10 +18,10 @@ import {
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
import { logApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
@@ -106,6 +106,7 @@ export const middleware = async (originalRequest: NextRequest) => {
request.headers.set("x-start-time", Date.now().toString());
// Create a new NextResponse object to forward the new request with headers
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
@@ -116,23 +117,25 @@ export const middleware = async (originalRequest: NextRequest) => {
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
const ip = await getClientIpFromHeaders();
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
return nextResponseWithCustomHeader;
}
let ip =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
ipAddress(request);
if (ip) {
try {
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
// NOSONAR - This is a catch all for rate limiting errors
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],
};
logApiErrorEdge(request, apiError);
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 429 });
}
}

View File

@@ -0,0 +1,74 @@
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { deleteUserAction } from "./actions";
// Mock all dependencies
vi.mock("@/lib/user/service", () => ({
deleteUser: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// add a mock to authenticatedActionClient.action
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
action: (fn: any) => {
return fn;
},
},
}));
describe("deleteUserAction", () => {
test("deletes user successfully when multi-org is enabled", async () => {
const ctx = { user: { id: "test-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "test-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("test-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("test-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("deletes user successfully when multi-org is disabled but user is not sole owner of any org", async () => {
const ctx = { user: { id: "another-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "another-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "another-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("another-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("another-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("throws OperationNotAllowedError when user is sole owner in at least one org and multi-org is disabled", async () => {
const ctx = { user: { id: "sole-owner-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([
{ id: "org-1" } as TOrganization,
]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
await expect(() => deleteUserAction({ ctx } as any)).rejects.toThrow(OperationNotAllowedError);
expect(deleteUser).not.toHaveBeenCalled();
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("sole-owner-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,29 +1,18 @@
"use server";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser, getUser } from "@/lib/user/service";
import { deleteUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { OperationNotAllowedError } from "@formbricks/types/errors";
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging(
"deleted",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
}
)
);
export const deleteUserAction = authenticatedActionClient.action(async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
return await deleteUser(ctx.user.id);
});

View File

@@ -1,28 +1,18 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as nextAuth from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import * as actions from "./actions";
import { DeleteAccountModal } from "./index";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next-auth/react", async () => {
const actual = await vi.importActual("next-auth/react");
return {
...actual,
signOut: vi.fn(),
};
});
vi.mock("./actions", () => ({
deleteUserAction: vi.fn(),
@@ -39,7 +29,6 @@ describe("DeleteAccountModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal with correct props", () => {
@@ -77,12 +66,7 @@ describe("DeleteAccountModal", () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
// Mock window.location.replace
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
});
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
render(
<DeleteAccountModal
@@ -102,11 +86,7 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
@@ -115,6 +95,7 @@ describe("DeleteAccountModal", () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
Object.defineProperty(window, "location", {
writable: true,
@@ -139,13 +120,8 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSignOut).toHaveBeenCalledWith({
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
);
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,9 +1,9 @@
"use client";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { T, useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
@@ -28,7 +28,6 @@ export const DeleteAccountModal = ({
const { t } = useTranslate();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -37,18 +36,12 @@ export const DeleteAccountModal = ({
try {
setDeleting(true);
await deleteUserAction();
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
});
// Manual redirect after signOut completes
// redirect to account deletion survey in Formbricks Cloud
if (isFormbricksCloud) {
await signOut({ redirect: true });
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
} else {
window.location.replace("/auth/login");
await signOut({ callbackUrl: "/auth/login" });
}
} catch (error) {
toast.error("Something went wrong");

View File

@@ -2,14 +2,14 @@
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Copy, QrCode, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSurveyUrl } from "../../utils";
import { LanguageDropdown } from "./components/LanguageDropdown";
import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
@@ -31,30 +31,46 @@ export const ShareSurveyLink = ({
const { t } = useTranslate();
const [language, setLanguage] = useState("default");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, surveyDomain, language);
setSurveyUrl(url);
} catch (error) {
const errorMessage = getFormattedErrorMessage(error);
const getUrl = useCallback(async () => {
let url = `${surveyDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {
const singleUseIdResponse = await generateSingleUseIdAction({
surveyId: survey.id,
isEncrypted: survey.singleUse.isEncrypted,
});
if (singleUseIdResponse?.data) {
queryParams.push(`suId=${singleUseIdResponse.data}`);
} else {
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
toast.error(errorMessage);
}
};
fetchSurveyUrl();
}, [survey, language, surveyDomain, setSurveyUrl]);
const generateNewSingleUseLink = async () => {
try {
const newUrl = await getSurveyUrl(survey, surveyDomain, language);
setSurveyUrl(newUrl);
toast.success(t("environments.surveys.new_single_use_link_generated"));
} catch (error) {
const errorMessage = getFormattedErrorMessage(error);
toast.error(errorMessage);
}
if (language !== "default") {
queryParams.push(`lang=${language}`);
}
if (queryParams.length) {
url += `?${queryParams.join("&")}`;
}
setSurveyUrl(url);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey, surveyDomain, language]);
const generateNewSingleUseLink = () => {
getUrl();
toast.success(t("environments.surveys.new_single_use_link_generated"));
};
useEffect(() => {
getUrl();
}, [survey, getUrl, language]);
const { downloadQRCode } = useSurveyQRCode(surveyUrl);
return (

View File

@@ -0,0 +1,202 @@
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createResponseNote, resolveResponseNote, updateResponseNote } from "@/lib/responseNote/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { describe, expect, test, vi } from "vitest";
import {
createResponseNoteAction,
createTagAction,
createTagToResponseAction,
deleteResponseAction,
deleteTagOnResponseAction,
getResponseAction,
resolveResponseNoteAction,
updateResponseNoteAction,
} from "./actions";
// Dummy inputs and context
const dummyCtx = { user: { id: "user1" } };
const dummyTagInput = { environmentId: "env1", tagName: "tag1" };
const dummyTagToResponseInput = { responseId: "resp1", tagId: "tag1" };
const dummyResponseIdInput = { responseId: "resp1" };
const dummyResponseNoteInput = { responseNoteId: "note1", text: "Updated note" };
const dummyCreateNoteInput = { responseId: "resp1", text: "New note" };
const dummyGetResponseInput = { responseId: "resp1" };
// Mocks for external dependencies
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getProjectIdFromEnvironmentId: vi.fn().mockResolvedValue("proj-env"),
getOrganizationIdFromResponseId: vi.fn().mockResolvedValue("org-resp"),
getOrganizationIdFromResponseNoteId: vi.fn().mockResolvedValue("org-resp-note"),
getProjectIdFromResponseId: vi.fn().mockResolvedValue("proj-resp"),
getProjectIdFromResponseNoteId: vi.fn().mockResolvedValue("proj-resp-note"),
getEnvironmentIdFromResponseId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getTag: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
deleteResponse: vi.fn().mockResolvedValue("deletedResponse"),
getResponse: vi.fn().mockResolvedValue({ data: "responseData" }),
}));
vi.mock("@/lib/responseNote/service", () => ({
createResponseNote: vi.fn().mockResolvedValue("createdNote"),
updateResponseNote: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNote: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/tag/service", () => ({
createTag: vi.fn().mockResolvedValue("createdTag"),
}));
vi.mock("@/lib/tagOnResponse/service", () => ({
addTagToRespone: vi.fn().mockResolvedValue("tagAdded"),
deleteTagOnResponse: vi.fn().mockResolvedValue("tagDeleted"),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => async (input: any) => {
const { user, ...rest } = input;
return fn({
parsedInput: rest,
ctx: { user },
});
},
}),
},
}));
describe("createTagAction", () => {
test("successfully creates a tag", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org1");
await createTagAction({ ...dummyTagInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(getProjectIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(createTag).toHaveBeenCalledWith(dummyTagInput.environmentId, dummyTagInput.tagName);
});
});
describe("createTagToResponseAction", () => {
test("adds tag to response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getEnvironmentIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(addTagToRespone).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteTagOnResponseAction", () => {
test("deletes tag on response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTagOnResponse).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteResponseAction", () => {
test("deletes response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await deleteResponseAction({ ...dummyResponseIdInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(deleteResponse).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
});
});
describe("updateResponseNoteAction", () => {
test("updates response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await updateResponseNoteAction({ ...dummyResponseNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(updateResponseNote).toHaveBeenCalledWith(
dummyResponseNoteInput.responseNoteId,
dummyResponseNoteInput.text
);
});
});
describe("resolveResponseNoteAction", () => {
test("resolves response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await resolveResponseNoteAction({ responseNoteId: "note1", ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(resolveResponseNote).toHaveBeenCalledWith("note1");
});
});
describe("createResponseNoteAction", () => {
test("creates a response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await createResponseNoteAction({ ...dummyCreateNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(createResponseNote).toHaveBeenCalledWith(
dummyCreateNoteInput.responseId,
dummyCtx.user.id,
dummyCreateNoteInput.text
);
});
});
describe("getResponseAction", () => {
test("retrieves response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await getResponseAction({ ...dummyGetResponseInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getResponse).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
});
});

View File

@@ -5,8 +5,7 @@ import { createResponseNote, resolveResponseNote, updateResponseNote } from "@/l
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
@@ -17,7 +16,6 @@ import {
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -26,266 +24,209 @@ const ZCreateTagAction = z.object({
tagName: z.string(),
});
export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction).action(
withAuditLogging(
"created",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
export const createTagAction = authenticatedActionClient
.schema(ZCreateTagAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
return await createTag(parsedInput.environmentId, parsedInput.tagName);
});
const ZCreateTagToResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const createTagToResponseAction = authenticatedActionClient.schema(ZCreateTagToResponseAction).action(
withAuditLogging(
"addedToResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
export const createTagToResponseAction = authenticatedActionClient
.schema(ZCreateTagToResponseAction)
.action(async ({ parsedInput, ctx }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
)
);
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
return await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
});
const ZDeleteTagOnResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDeleteTagOnResponseAction).action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
export const deleteTagOnResponseAction = authenticatedActionClient
.schema(ZDeleteTagOnResponseAction)
.action(async ({ parsedInput, ctx }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
)
);
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
return await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
});
const ZDeleteResponseAction = z.object({
responseId: ZId,
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(
withAuditLogging(
"deleted",
"response",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseId = parsedInput.responseId;
const result = await deleteResponse(parsedInput.responseId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
export const deleteResponseAction = authenticatedActionClient
.schema(ZDeleteResponseAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
return await deleteResponse(parsedInput.responseId);
});
const ZUpdateResponseNoteAction = z.object({
responseNoteId: ZId,
text: z.string(),
});
export const updateResponseNoteAction = authenticatedActionClient.schema(ZUpdateResponseNoteAction).action(
withAuditLogging(
"updated",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseNoteId = parsedInput.responseNoteId;
const result = await updateResponseNote(parsedInput.responseNoteId, parsedInput.text);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const updateResponseNoteAction = authenticatedActionClient
.schema(ZUpdateResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
return await updateResponseNote(parsedInput.responseNoteId, parsedInput.text);
});
const ZResolveResponseNoteAction = z.object({
responseNoteId: ZId,
});
export const resolveResponseNoteAction = authenticatedActionClient.schema(ZResolveResponseNoteAction).action(
withAuditLogging(
"updated",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.responseNoteId = parsedInput.responseNoteId;
const result = await resolveResponseNote(parsedInput.responseNoteId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const resolveResponseNoteAction = authenticatedActionClient
.schema(ZResolveResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId),
minPermission: "readWrite",
},
],
});
await resolveResponseNote(parsedInput.responseNoteId);
});
const ZCreateResponseNoteAction = z.object({
responseId: ZId,
text: z.string(),
});
export const createResponseNoteAction = authenticatedActionClient.schema(ZCreateResponseNoteAction).action(
withAuditLogging(
"created",
"responseNote",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createResponseNote(parsedInput.responseId, ctx.user.id, parsedInput.text);
ctx.auditLoggingCtx.newObject = result;
ctx.auditLoggingCtx.responseNoteId = result.id;
return result;
}
)
);
export const createResponseNoteAction = authenticatedActionClient
.schema(ZCreateResponseNoteAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromResponseId(parsedInput.responseId),
minPermission: "readWrite",
},
],
});
return await createResponseNote(parsedInput.responseId, ctx.user.id, parsedInput.text);
});
const ZGetResponseAction = z.object({
responseId: ZId,

View File

@@ -3,14 +3,6 @@ import { isValidElement } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { renderHyperlinkedContent } from "./utils";
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn(),
}));
describe("renderHyperlinkedContent", () => {
afterEach(() => {
cleanup();

View File

@@ -1,7 +1,4 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { JSX } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
// Utility function to render hyperlinked content
export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
@@ -29,36 +26,3 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
)
);
};
export const getSurveyUrl = async (
survey: TSurvey,
surveyDomain: string,
language: string
): Promise<string> => {
let url = `${surveyDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {
const singleUseIdResponse = await generateSingleUseIdAction({
surveyId: survey.id,
isEncrypted: survey.singleUse.isEncrypted,
});
if (singleUseIdResponse?.data) {
queryParams.push(`suId=${singleUseIdResponse.data}`);
} else {
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
throw new Error(errorMessage);
}
}
if (language !== "default") {
queryParams.push(`lang=${language}`);
}
if (queryParams.length) {
url += `?${queryParams.join("&")}`;
}
return url;
};

View File

@@ -1,4 +1,3 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { ZodRawShape, z } from "zod";
@@ -9,12 +8,10 @@ export type HandlerFn<TInput = Record<string, unknown>> = ({
authentication,
parsedInput,
request,
auditLog,
}: {
authentication: TAuthenticationApiKey;
parsedInput: TInput;
request: Request;
auditLog?: ApiAuditLog;
}) => Promise<Response>;
export type ExtendedSchemas = {
@@ -44,25 +41,18 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
externalParams,
rateLimit = true,
handler,
auditLog,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
auditLog?: ApiAuditLog;
}): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication.ok) {
return handleApiError(request, authentication.error);
}
if (auditLog) {
auditLog.userId = authentication.data.apiKeyId;
auditLog.organizationId = authentication.data.organizationId;
}
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
@@ -116,6 +106,5 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
authentication: authentication.data,
parsedInput,
request,
auditLog,
});
};

View File

@@ -1,7 +1,5 @@
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
@@ -10,35 +8,24 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
externalParams,
rateLimit = true,
handler,
action,
targetType,
}: {
request: Request;
schemas?: S;
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
action?: TAuditAction;
targetType?: TAuditTarget;
}): Promise<Response> => {
try {
const auditLog =
action && targetType ? buildAuditLogBaseObject(action, targetType, request.url) : undefined;
const response = await apiWrapper({
request,
schemas,
externalParams,
rateLimit,
handler,
auditLog,
});
if (response.ok) {
if (auditLog) {
auditLog.status = "success";
}
logApiRequest(request, response.status, auditLog);
logApiRequest(request, response.status);
}
return response;

View File

@@ -18,9 +18,6 @@ vi.mock("@sentry/nextjs", () => ({
vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mocked-sentry-dsn",
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: "mock-url",
}));
describe("utils", () => {

View File

@@ -1,30 +0,0 @@
// Function is this file can be used in edge runtime functions, like api routes.
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
}
logger
.withContext({
correlationId,
error,
})
.error("API Error Details");
};

View File

@@ -1,19 +1,14 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import * as Sentry from "@sentry/nextjs";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: ApiAuditLog
): Response => {
logApiError(request, err, auditLog);
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
logApiError(request, err);
switch (err.type) {
case "bad_request":
@@ -55,7 +50,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
export const logApiRequest = (request: Request, responseStatus: number): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -78,22 +73,29 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
queryParams: safeQueryParams,
})
.info("API Request Details");
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
logApiErrorEdge(request, error);
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
logAuditLog(request, auditLog);
};
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({
...auditLog,
eventId: correlationId,
}).catch((err) => logger.error({ err, correlationId }, "Failed to queue audit event from logApiError"));
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
}
logger
.withContext({
correlationId,
error,
})
.error("API Error Details");
};

View File

@@ -56,55 +56,36 @@ export const PUT = async (
body: ZContactAttributeKeyUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params.contactAttributeKeyId;
}
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, res.error as ApiErrorResponseV2);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
});
}
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
if (!updatedContactAttributeKey.ok) {
return handleApiError(request, updatedContactAttributeKey.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = res.data;
auditLog.newObject = updatedContactAttributeKey.data;
return handleApiError(request, updatedContactAttributeKey.error as ApiErrorResponseV2);
}
return responses.successResponse(updatedContactAttributeKey);
},
action: "updated",
targetType: "contactAttributeKey",
});
export const DELETE = async (
@@ -117,53 +98,35 @@ export const DELETE = async (
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params.contactAttributeKeyId;
}
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, res.error as ApiErrorResponseV2);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contactAttributeKey" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
});
}
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
if (!deletedContactAttributeKey.ok) {
return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = res.data;
return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2);
}
return responses.successResponse(deletedContactAttributeKey);
},
action: "deleted",
targetType: "contactAttributeKey",
});

View File

@@ -51,35 +51,24 @@ export const POST = async (request: NextRequest) =>
schemas: {
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(
request,
{
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
},
auditLog
);
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
});
}
const createContactAttributeKeyResult = await createContactAttributeKey(body);
if (!createContactAttributeKeyResult.ok) {
return handleApiError(request, createContactAttributeKeyResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createContactAttributeKeyResult.data.id;
auditLog.newObject = createContactAttributeKeyResult.data;
return handleApiError(request, createContactAttributeKeyResult.error as ApiErrorResponseV2);
}
return responses.createdResponse(createContactAttributeKeyResult);
},
action: "created",
targetType: "contactAttributeKey",
});

View File

@@ -59,53 +59,35 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params.responseId;
}
if (!params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
});
}
const response = await deleteResponse(params.responseId);
if (!response.ok) {
return handleApiError(request, response.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = response.data;
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse(response);
},
action: "deleted",
targetType: "response",
});
export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -116,56 +98,44 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
params: z.object({ responseId: ZResponseIdSchema }),
body: ZResponseUpdateSchema,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { body, params } = parsedInput;
if (!body || !params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.responseId, true);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
});
}
const existingResponse = await getResponse(params.responseId);
if (!existingResponse.ok) {
return handleApiError(request, existingResponse.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, existingResponse.error as ApiErrorResponseV2);
}
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
if (!questionsResponse.ok) {
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2);
}
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
}
// Validate response data for "other" options exceeding character limit
@@ -193,16 +163,9 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
const response = await updateResponse(params.responseId, body);
if (!response.ok) {
return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = existingResponse.data;
auditLog.newObject = response.data;
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse(response);
},
action: "updated",
targetType: "response",
});

View File

@@ -51,36 +51,28 @@ export const POST = async (request: Request) =>
schemas: {
body: ZResponseInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(body.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(
request,
{
type: "unauthorized",
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
});
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
@@ -90,18 +82,14 @@ export const POST = async (request: Request) =>
const surveyQuestions = await getSurveyQuestions(body.surveyId);
if (!surveyQuestions.ok) {
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2);
}
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "response", issue: "Invalid file upload response" }],
});
}
// Validate response data for "other" options exceeding character limit
@@ -128,16 +116,9 @@ export const POST = async (request: Request) =>
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createResponseResult.data.id;
auditLog.newObject = createResponseResult.data;
return handleApiError(request, createResponseResult.error);
}
return responses.createdResponse({ data: createResponseResult.data });
},
action: "created",
targetType: "response",
});

View File

@@ -58,77 +58,55 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
body: ZWebhookUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.webhookId;
}
if (!body || !params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
});
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error, auditLog);
return handleApiError(request, surveysEnvironmentId.error);
}
// get webhook environment
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, webhook.error as ApiErrorResponseV2);
}
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
});
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
],
});
}
const updatedWebhook = await updateWebhook(params.webhookId, body);
if (!updatedWebhook.ok) {
return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = webhook.data;
auditLog.newObject = updatedWebhook.data;
return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2);
}
return responses.successResponse(updatedWebhook);
},
action: "updated",
targetType: "webhook",
});
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
@@ -138,52 +116,35 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
params: z.object({ webhookId: ZWebhookIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.webhookId;
}
if (!params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const webhook = await getWebhook(params.webhookId);
if (!webhook.ok) {
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
return handleApiError(request, webhook.error as ApiErrorResponseV2);
}
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "webhook", issue: "unauthorized" }],
});
}
const deletedWebhook = await deleteWebhook(params.webhookId);
if (!deletedWebhook.ok) {
return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
}
if (auditLog) {
auditLog.oldObject = webhook.data;
return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2);
}
return responses.successResponse(deletedWebhook);
},
action: "deleted",
targetType: "webhook",
});

View File

@@ -43,50 +43,35 @@ export const POST = async (request: NextRequest) =>
schemas: {
body: ZWebhookInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
return handleApiError(request, environmentIdResult.error);
}
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
},
auditLog
);
return handleApiError(request, {
type: "forbidden",
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
});
}
const createWebhookResult = await createWebhook(body);
if (!createWebhookResult.ok) {
return handleApiError(request, createWebhookResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createWebhookResult.data.id;
auditLog.newObject = createWebhookResult.data;
return handleApiError(request, createWebhookResult.error);
}
return responses.createdResponse(createWebhookResult);
},
action: "created",
targetType: "webhook",
});

View File

@@ -4,9 +4,7 @@ import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import {
createProjectTeam,
@@ -55,28 +53,20 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
handler: async ({ parsedInput: { body, params }, authentication }) => {
const { teamId, projectId } = body!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error, auditLog);
return handleApiError(request, hasAccess.error);
}
// check if project team already exists
@@ -90,32 +80,22 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
});
if (!existingProjectTeam.ok) {
return handleApiError(request, existingProjectTeam.error, auditLog);
return handleApiError(request, existingProjectTeam.error);
}
if (existingProjectTeam.data.data.length > 0) {
return handleApiError(
request,
{
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
},
auditLog
);
return handleApiError(request, {
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
});
}
const result = await createProjectTeam(body!);
if (!result.ok) {
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.newObject = result.data;
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
action: "created",
targetType: "projectTeam",
});
}
@@ -127,65 +107,29 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
handler: async ({ parsedInput: { body, params }, authentication }) => {
const { teamId, projectId } = body!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error, auditLog);
}
// Fetch old object for audit log
let oldProjectTeamData: any = UNKNOWN_DATA;
try {
const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) {
oldProjectTeamData = oldProjectTeamResult.data.data[0];
} else {
logger.error(`Failed to fetch old project team data for audit log`);
}
} catch (error) {
logger.error(error, `Failed to fetch old project team data for audit log`);
return handleApiError(request, hasAccess.error);
}
const result = await updateProjectTeam(teamId, projectId, body!);
if (!result.ok) {
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldProjectTeamData;
auditLog.newObject = result.data;
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
action: "updated",
targetType: "projectTeam",
});
}
@@ -197,63 +141,28 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication, auditLog }) => {
handler: async ({ parsedInput: { query, params }, authentication }) => {
const { teamId, projectId } = query!;
if (auditLog) {
auditLog.targetId = `${projectId}-${teamId}`;
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error, auditLog);
}
// Fetch old object for audit log
let oldProjectTeamData: any = UNKNOWN_DATA;
try {
const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) {
oldProjectTeamData = oldProjectTeamResult.data.data[0];
} else {
logger.error(`Failed to fetch old project team data for audit log`);
}
} catch (error) {
logger.error(error, `Failed to fetch old project team data for audit log`);
return handleApiError(request, hasAccess.error);
}
const result = await deleteProjectTeam(teamId, projectId);
if (!result.ok) {
return handleApiError(request, result.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldProjectTeamData;
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data });
},
action: "deleted",
targetType: "projectTeam",
});
}

View File

@@ -13,9 +13,7 @@ import {
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (
@@ -55,46 +53,22 @@ export const DELETE = async (
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params }, auditLog }) => {
if (auditLog) {
auditLog.targetId = params.teamId;
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
if (!hasOrganizationIdAndAccess(params.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
let oldTeamData: any = UNKNOWN_DATA;
try {
const oldTeamResult = await getTeam(params.organizationId, params.teamId);
if (oldTeamResult.ok) {
oldTeamData = oldTeamResult.data;
}
} catch (error) {
logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`);
}
const team = await deleteTeam(params.organizationId, params.teamId);
const team = await deleteTeam(params!.organizationId, params!.teamId);
if (!team.ok) {
return handleApiError(request, team.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldTeamData;
return handleApiError(request, team.error as ApiErrorResponseV2);
}
return responses.successResponse(team);
},
action: "deleted",
targetType: "team",
});
export const PUT = (
@@ -108,45 +82,20 @@ export const PUT = (
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
body: ZTeamUpdateSchema,
},
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
if (auditLog) {
auditLog.targetId = params.teamId;
}
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
}
let oldTeamData: any = UNKNOWN_DATA;
try {
const oldTeamResult = await getTeam(params.organizationId, params.teamId);
if (oldTeamResult.ok) {
oldTeamData = oldTeamResult.data;
}
} catch (error) {
logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await updateTeam(params!.organizationId, params!.teamId, body!);
if (!team.ok) {
return handleApiError(request, team.error, auditLog);
}
if (auditLog) {
auditLog.oldObject = oldTeamData;
auditLog.newObject = team.data;
return handleApiError(request, team.error as ApiErrorResponseV2);
}
return responses.successResponse(team);
},
action: "updated",
targetType: "team",
});

View File

@@ -46,30 +46,19 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const createTeamResult = await createTeam(body!, authentication.organizationId);
if (!createTeamResult.ok) {
return handleApiError(request, createTeamResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createTeamResult.data.id;
auditLog.newObject = createTeamResult.data;
return handleApiError(request, createTeamResult.error);
}
return responses.createdResponse({ data: createTeamResult.data });
},
action: "created",
targetType: "team",
});

View File

@@ -14,10 +14,8 @@ import {
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
@@ -61,45 +59,28 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const createUserResult = await createUser(body!, authentication.organizationId);
if (!createUserResult.ok) {
return handleApiError(request, createUserResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createUserResult.data.id;
auditLog.newObject = createUserResult.data;
return handleApiError(request, createUserResult.error);
}
return responses.createdResponse({ data: createUserResult.data });
},
action: "created",
targetType: "user",
});
export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
@@ -110,75 +91,33 @@ export const PATCH = async (request: Request, props: { params: Promise<{ organiz
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (IS_FORMBRICKS_CLOUD) {
return handleApiError(
request,
{
type: "bad_request",
details: [
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
],
},
auditLog
);
return handleApiError(request, {
type: "bad_request",
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
});
}
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(
request,
{
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
},
auditLog
);
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
if (!body?.email) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "email", issue: "Email is required" }],
},
auditLog
);
}
let oldUserData: any = UNKNOWN_DATA;
try {
const oldUserResult = await getUsers(authentication.organizationId, {
email: body.email,
limit: 1,
skip: 0,
sortBy: "createdAt",
order: "desc",
return handleApiError(request, {
type: "bad_request",
details: [{ field: "email", issue: "Email is required" }],
});
if (oldUserResult.ok) {
oldUserData = oldUserResult.data.data[0];
}
} catch (error) {
logger.error(`Failed to fetch old user data for audit log: ${JSON.stringify(error)}`);
}
if (auditLog) {
auditLog.targetId = oldUserData !== UNKNOWN_DATA ? oldUserData?.id : UNKNOWN_DATA;
}
const updateUserResult = await updateUser(body, authentication.organizationId);
if (!updateUserResult.ok) {
return handleApiError(request, updateUserResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = auditLog.targetId === UNKNOWN_DATA ? updateUserResult.data.id : auditLog.targetId;
auditLog.oldObject = oldUserData;
auditLog.newObject = updateUserResult.data;
return handleApiError(request, updateUserResult.error);
}
return responses.successResponse({ data: updateUserResult.data });
},
action: "updated",
targetType: "user",
});

View File

@@ -1,149 +0,0 @@
import { logSignOut } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { logSignOutAction } from "./sign-out";
// Mock the dependencies
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
logSignOut: vi.fn(),
}));
// Clear the existing mock from vitestSetup.ts
vi.unmock("@/modules/auth/actions/sign-out");
describe("logSignOutAction", () => {
const mockUserId = "user123";
const mockUserEmail = "test@example.com";
const mockContext = {
reason: "user_initiated" as const,
redirectUrl: "https://example.com",
organizationId: "org123",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("calls logSignOut with correct parameters", async () => {
await logSignOutAction(mockUserId, mockUserEmail, mockContext);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, mockContext);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with minimal parameters", async () => {
const minimalContext = {};
await logSignOutAction(mockUserId, mockUserEmail, minimalContext);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, minimalContext);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only reason", async () => {
const contextWithReason = { reason: "session_timeout" as const };
await logSignOutAction(mockUserId, mockUserEmail, contextWithReason);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithReason);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only redirectUrl", async () => {
const contextWithRedirectUrl = { redirectUrl: "https://redirect.com" };
await logSignOutAction(mockUserId, mockUserEmail, contextWithRedirectUrl);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithRedirectUrl);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("calls logSignOut with context containing only organizationId", async () => {
const contextWithOrgId = { organizationId: "org456" };
await logSignOutAction(mockUserId, mockUserEmail, contextWithOrgId);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithOrgId);
expect(logSignOut).toHaveBeenCalledTimes(1);
});
test("handles all possible reason values", async () => {
const reasons = [
"user_initiated",
"account_deletion",
"email_change",
"session_timeout",
"forced_logout",
] as const;
for (const reason of reasons) {
const context = { reason };
await logSignOutAction(mockUserId, mockUserEmail, context);
expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, context);
}
expect(logSignOut).toHaveBeenCalledTimes(reasons.length);
});
test("logs error and re-throws when logSignOut throws an Error", async () => {
const mockError = new Error("Failed to log sign out");
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("logs error and re-throws when logSignOut throws a non-Error", async () => {
const mockError = "String error";
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("logs error with empty context when logSignOut throws", async () => {
const mockError = new Error("Failed to log sign out");
const emptyContext = {};
vi.mocked(logSignOut).mockImplementation(() => {
throw mockError;
});
await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: emptyContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
test("does not log error when logSignOut succeeds", async () => {
await logSignOutAction(mockUserId, mockUserEmail, mockContext);
expect(logger.error).not.toHaveBeenCalled();
});
});

View File

@@ -1,32 +0,0 @@
"use server";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logger } from "@formbricks/logger";
/**
* Logs a sign out event
* @param userId - The ID of the user who signed out
* @param userEmail - The email of the user who signed out
* @param context - The context of the sign out event
*/
export const logSignOutAction = async (
userId: string,
userEmail: string,
context: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
redirectUrl?: string;
organizationId?: string;
}
) => {
try {
logSignOut(userId, userEmail, context);
} catch (error) {
logger.error("Failed to log sign out event", {
userId,
context,
error: error instanceof Error ? error.message : String(error),
});
// Re-throw to ensure callers are aware of the failure
throw error;
}
};

Some files were not shown because too many files have changed in this diff Show More