mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 11:59:54 -06:00
feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -3,4 +3,5 @@ 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)
|
||||
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.
|
||||
@@ -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,3 +216,8 @@ 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
|
||||
|
||||
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
@@ -45,6 +45,16 @@ 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
|
||||
|
||||
@@ -86,6 +86,8 @@ 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", () => ({
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
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 }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock our useSignOut hook
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: () => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
@@ -70,6 +88,12 @@ describe("LandingSidebar component", () => {
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 {
|
||||
@@ -20,7 +21,6 @@ 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,6 +44,7 @@ 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();
|
||||
|
||||
@@ -123,7 +124,13 @@ export const LandingSidebar = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -89,6 +89,8 @@ 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");
|
||||
|
||||
@@ -98,6 +98,8 @@ 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", () => ({
|
||||
|
||||
@@ -35,6 +35,8 @@ 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", () => ({
|
||||
|
||||
@@ -34,6 +34,8 @@ 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
|
||||
|
||||
@@ -26,6 +26,8 @@ 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", () => {
|
||||
|
||||
@@ -4,7 +4,9 @@ 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-middleware";
|
||||
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 {
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient
|
||||
.schema(ZCreateProjectAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
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");
|
||||
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,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
return project;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
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-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
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";
|
||||
@@ -14,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
updatedAction: ZActionClassInput,
|
||||
});
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,6 +10,17 @@ 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() })),
|
||||
@@ -18,6 +29,9 @@ 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(),
|
||||
}));
|
||||
@@ -203,7 +217,9 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
@@ -224,7 +240,13 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -42,7 +43,6 @@ 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,6 +90,7 @@ 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);
|
||||
@@ -389,8 +390,14 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
|
||||
router.push(route.url);
|
||||
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
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
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";
|
||||
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
|
||||
export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrUpdateIntegrationAction)
|
||||
.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),
|
||||
},
|
||||
],
|
||||
});
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"createdUpdated",
|
||||
"integration",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteIntegrationAction = z.object({
|
||||
integrationId: ZId,
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
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);
|
||||
|
||||
return await deleteIntegration(parsedInput.integrationId);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -49,6 +49,8 @@ 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");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
|
||||
@@ -32,6 +32,8 @@ 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getSlackChannels } from "@/lib/slack/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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", () => {
|
||||
|
||||
@@ -41,6 +41,8 @@ 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);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { getUser, 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";
|
||||
|
||||
@@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({
|
||||
|
||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||
.schema(ZUpdateNotificationSettingsAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await updateUser(ctx.user.id, {
|
||||
notificationSettings: parsedInput.notificationSettings,
|
||||
});
|
||||
});
|
||||
.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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { getUser, 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";
|
||||
@@ -27,93 +29,136 @@ 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(async ({ parsedInput, ctx }) => {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
.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 });
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
|
||||
// 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
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveAvatarAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const removeAvatarAction = authenticatedActionClient
|
||||
.schema(ZRemoveAvatarAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const imageUrl = ctx.user.imageUrl;
|
||||
if (!imageUrl) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
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 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 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;
|
||||
}
|
||||
return await updateUser(ctx.user.id, { imageUrl: null });
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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,
|
||||
@@ -16,8 +17,6 @@ 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";
|
||||
@@ -39,7 +38,6 @@ export const EditProfileDetailsForm = ({
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: {
|
||||
@@ -53,6 +51,7 @@ 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();
|
||||
@@ -86,8 +85,12 @@ export const EditProfileDetailsForm = ({
|
||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
await signOutWithAudit({
|
||||
reason: "email_change",
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
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 { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationNameAction)
|
||||
.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);
|
||||
});
|
||||
.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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = authenticatedActionClient
|
||||
.schema(ZDeleteOrganizationAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
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");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
});
|
||||
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);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ 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", () => {
|
||||
|
||||
@@ -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-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -45,6 +45,8 @@ 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");
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
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-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
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";
|
||||
@@ -63,37 +65,55 @@ const ZGenerateResultShareUrlAction = z.object({
|
||||
|
||||
export const generateResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGenerateResultShareUrlAction)
|
||||
.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),
|
||||
},
|
||||
],
|
||||
});
|
||||
.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),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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
|
||||
)();
|
||||
|
||||
await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
return resultShareKey;
|
||||
});
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return resultShareKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -132,30 +152,50 @@ const ZDeleteResultShareUrlAction = z.object({
|
||||
|
||||
export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZDeleteResultShareUrlAction)
|
||||
.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),
|
||||
},
|
||||
],
|
||||
});
|
||||
.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),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return await updateSurvey({ ...survey, resultShareKey: null });
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
|
||||
@@ -7,6 +7,20 @@ 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,
|
||||
@@ -30,7 +44,9 @@ 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
|
||||
|
||||
@@ -5,8 +5,10 @@ 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-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
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";
|
||||
@@ -14,7 +16,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 { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const ZGetResponsesDownloadUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -102,39 +104,54 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { followUps } = parsedInput;
|
||||
const { followUps } = parsedInput;
|
||||
|
||||
if (parsedInput.recaptcha?.enabled) {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
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 (followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.languages?.length) {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
}
|
||||
|
||||
return await updateSurvey(parsedInput);
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ 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", () => ({
|
||||
|
||||
@@ -7,6 +7,8 @@ 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";
|
||||
@@ -179,10 +181,33 @@ export const POST = async (request: Request) => {
|
||||
|
||||
// Update survey status if necessary
|
||||
if (survey.autoComplete && responseCount >= survey.autoComplete) {
|
||||
await updateSurvey({
|
||||
...survey,
|
||||
status: "completed",
|
||||
});
|
||||
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 webhook and email promises with allSettled to prevent early rejection
|
||||
|
||||
@@ -1,8 +1,140 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
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 NextAuth from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
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);
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -44,63 +45,104 @@ export const GET = async (
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
export const PUT = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
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 authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
|
||||
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error)
|
||||
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
|
||||
);
|
||||
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),
|
||||
};
|
||||
}
|
||||
const updatedActionClass = await updateActionClass(
|
||||
inputValidation.data.environmentId,
|
||||
params.actionClassId,
|
||||
inputValidation.data
|
||||
);
|
||||
if (updatedActionClass) {
|
||||
return responses.successResponse(updatedActionClass);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Some error ocured while updating action");
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
"updated",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
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);
|
||||
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 deletedActionClass = await deleteActionClass(params.actionClassId);
|
||||
return responses.successResponse(deletedActionClass);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
"deleted",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -28,41 +29,62 @@ export const GET = async (request: Request) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
let actionClassInput;
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
actionClassInput = await request.json();
|
||||
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),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
},
|
||||
"created",
|
||||
"actionClass"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -48,58 +49,101 @@ export const GET = async (
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.responseId;
|
||||
try {
|
||||
responseUpdate = await request.json();
|
||||
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),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
"deleted",
|
||||
"response"
|
||||
);
|
||||
|
||||
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload 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 inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error)
|
||||
);
|
||||
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),
|
||||
};
|
||||
}
|
||||
return responses.successResponse(await updateResponse(params.responseId, inputValidation.data));
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
"updated",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -91,46 +92,78 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
|
||||
return { survey };
|
||||
};
|
||||
|
||||
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 inputResult.error;
|
||||
|
||||
const responseInput = inputResult.data;
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
const response = await createResponse(responseInput);
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const inputResult = await validateInput(request);
|
||||
if (inputResult.error) {
|
||||
return {
|
||||
response: inputResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
"created",
|
||||
"response"
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -42,64 +43,121 @@ export const GET = async (
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
|
||||
let surveyUpdate;
|
||||
export const DELETE = withApiLogging(
|
||||
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
|
||||
const params = await props.params;
|
||||
auditLog.targetId = params.surveyId;
|
||||
try {
|
||||
surveyUpdate = await request.json();
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
|
||||
if (result.error) {
|
||||
return {
|
||||
response: result.error,
|
||||
};
|
||||
}
|
||||
auditLog.oldObject = result.survey;
|
||||
|
||||
const deletedSurvey = await deleteSurvey(params.surveyId);
|
||||
return {
|
||||
response: responses.successResponse(deletedSurvey),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
"deleted",
|
||||
"survey"
|
||||
);
|
||||
|
||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||
...result.survey,
|
||||
...surveyUpdate,
|
||||
});
|
||||
export const PUT = 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;
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error)
|
||||
);
|
||||
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),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
auditLog.status = "failure";
|
||||
return {
|
||||
response: handleErrorResponse(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);
|
||||
}
|
||||
};
|
||||
},
|
||||
"updated",
|
||||
"survey"
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -33,50 +34,78 @@ export const GET = async (request: Request) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
let surveyInput;
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
try {
|
||||
surveyInput = await request.json();
|
||||
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),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
},
|
||||
"created",
|
||||
"survey"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -28,33 +29,54 @@ export const GET = async (request: Request, props: { params: Promise<{ webhookId
|
||||
return responses.successResponse(webhook);
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
auditLog.oldObject = 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);
|
||||
}
|
||||
};
|
||||
// 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"
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
@@ -25,43 +26,65 @@ export const GET = async (request: Request) => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
export const POST = withApiLogging(
|
||||
async (request: Request, _, auditLog: ApiAuditLog) => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return {
|
||||
response: responses.notAuthenticatedResponse(),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.internalServerErrorResponse(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
|
||||
),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
277
apps/web/app/lib/api/with-api-logging.test.ts
Normal file
277
apps/web/app/lib/api/with-api-logging.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
103
apps/web/app/lib/api/with-api-logging.ts
Normal file
103
apps/web/app/lib/api/with-api-logging.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,8 @@ 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";
|
||||
@@ -12,24 +14,31 @@ const ZCreateOrganizationAction = z.object({
|
||||
organizationName: z.string(),
|
||||
});
|
||||
|
||||
export const createOrganizationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrganizationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
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();
|
||||
|
||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
|
||||
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;
|
||||
}
|
||||
|
||||
const newOrganization = await createOrganization({
|
||||
name: parsedInput.organizationName,
|
||||
});
|
||||
|
||||
await createMembership(newOrganization.id, ctx.user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,9 +3,13 @@ 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";
|
||||
|
||||
@@ -57,46 +61,161 @@ export const GET = async (
|
||||
};
|
||||
|
||||
export const DELETE = async (
|
||||
_: NextRequest,
|
||||
props: { params: Promise<{ fileName: string }> }
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ environmentId: string; accessType: string; 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, file] = params.fileName.split("/");
|
||||
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 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();
|
||||
}
|
||||
|
||||
// check if the user has access to the environment
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
// Authorization
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId);
|
||||
if (!isUserAuthorized) {
|
||||
await logFileDeletion({
|
||||
failureReason: "User not authorized to access environment",
|
||||
accessType: validAccessType,
|
||||
userId: session.user.id,
|
||||
});
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return await handleDeleteFile(
|
||||
paramValidation.data.environmentId,
|
||||
paramValidation.data.accessType,
|
||||
paramValidation.data.fileName
|
||||
);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -282,4 +281,11 @@ 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;
|
||||
|
||||
@@ -105,7 +105,12 @@ 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(),
|
||||
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).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(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -201,6 +206,8 @@ 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,100 +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,
|
||||
};
|
||||
|
||||
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");
|
||||
};
|
||||
120
apps/web/lib/utils/action-client/action-client-middleware.ts
Normal file
120
apps/web/lib/utils/action-client/action-client-middleware.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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");
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
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,
|
||||
@@ -13,10 +17,16 @@ import {
|
||||
TooManyRequestsError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { ActionClientCtx } from "./types/context";
|
||||
|
||||
export const actionClient = createSafeActionClient({
|
||||
handleServerError(e) {
|
||||
Sentry.captureException(e);
|
||||
handleServerError(e, utils) {
|
||||
const eventId = (utils.ctx as Record<string, any>)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback
|
||||
Sentry.captureException(e, {
|
||||
extra: {
|
||||
eventId,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
e instanceof ResourceNotFoundError ||
|
||||
@@ -31,12 +41,28 @@ export const actionClient = createSafeActionClient({
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors
|
||||
logger.error(e, "SERVER ERROR");
|
||||
logger.withContext({ eventId }).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 ({ next }) => {
|
||||
export const authenticatedActionClient = actionClient.use(async ({ ctx, next }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
@@ -49,5 +75,5 @@ export const authenticatedActionClient = actionClient.use(async ({ next }) => {
|
||||
throw new AuthorizationError("User not found");
|
||||
}
|
||||
|
||||
return next({ ctx: { user } });
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
34
apps/web/lib/utils/action-client/types/context.ts
Normal file
34
apps/web/lib/utils/action-client/types/context.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
};
|
||||
82
apps/web/lib/utils/client-ip.test.ts
Normal file
82
apps/web/lib/utils/client-ip.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
22
apps/web/lib/utils/client-ip.ts
Normal file
22
apps/web/lib/utils/client-ip.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_error": "Beim Aktualisieren der Kontakte ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_error": "Something went wrong while refreshing contacts, please try again",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"first_name": "First Name",
|
||||
"last_name": "Last Name",
|
||||
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||
"contacts_table_refresh_error": "Une erreur s'est produite lors de la mise à jour des contacts. Veuillez réessayer.",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom de famille",
|
||||
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_error": "Ocorreu um erro ao atualizar os contatos. Por favor, tente novamente.",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Sobrenome",
|
||||
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_error": "Algo correu mal ao atualizar os contactos, por favor, tente novamente",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"first_name": "Primeiro Nome",
|
||||
"last_name": "Apelido",
|
||||
|
||||
@@ -597,7 +597,6 @@
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_error": "重新整理聯絡人時發生錯誤,請再試一次",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"first_name": "名字",
|
||||
"last_name": "姓氏",
|
||||
|
||||
@@ -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 { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
|
||||
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,7 +106,6 @@ 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,
|
||||
@@ -117,25 +116,23 @@ 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." }],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
logApiErrorEdge(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 429 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,29 @@
|
||||
"use server";
|
||||
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteUser } from "@/lib/user/service";
|
||||
import { deleteUser, getUser } 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(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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
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";
|
||||
|
||||
vi.mock("next-auth/react", async () => {
|
||||
const actual = await vi.importActual("next-auth/react");
|
||||
return {
|
||||
...actual,
|
||||
signOut: vi.fn(),
|
||||
};
|
||||
});
|
||||
// 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("./actions", () => ({
|
||||
deleteUserAction: vi.fn(),
|
||||
@@ -29,6 +39,7 @@ describe("DeleteAccountModal", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders modal with correct props", () => {
|
||||
@@ -66,7 +77,12 @@ 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);
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { replace: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<DeleteAccountModal
|
||||
@@ -86,7 +102,11 @@ describe("DeleteAccountModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
});
|
||||
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +115,6 @@ 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,
|
||||
@@ -120,8 +139,13 @@ describe("DeleteAccountModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: true });
|
||||
expect(window.location.replace).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(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +28,7 @@ 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);
|
||||
};
|
||||
@@ -36,12 +37,18 @@ export const DeleteAccountModal = ({
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
// redirect to account deletion survey in Formbricks Cloud
|
||||
|
||||
// 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
|
||||
if (isFormbricksCloud) {
|
||||
await signOut({ redirect: true });
|
||||
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
|
||||
} else {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
window.location.replace("/auth/login");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong");
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ 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-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getEnvironmentIdFromResponseId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
@@ -16,6 +17,7 @@ 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";
|
||||
|
||||
@@ -24,209 +26,266 @@ const ZCreateTagAction = z.object({
|
||||
tagName: z.string(),
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
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);
|
||||
|
||||
return await createTag(parsedInput.environmentId, parsedInput.tagName);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZCreateTagToResponseAction = z.object({
|
||||
responseId: ZId,
|
||||
tagId: ZId,
|
||||
});
|
||||
|
||||
export const createTagToResponseAction = authenticatedActionClient
|
||||
.schema(ZCreateTagToResponseAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
|
||||
const tagEnvironment = await getTag(parsedInput.tagId);
|
||||
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);
|
||||
|
||||
if (!responseEnvironmentId || !tagEnvironment) {
|
||||
throw new Error("Environment not found");
|
||||
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.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(async ({ parsedInput, ctx }) => {
|
||||
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
|
||||
const tagEnvironment = await getTag(parsedInput.tagId);
|
||||
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");
|
||||
}
|
||||
|
||||
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,
|
||||
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.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(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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateResponseNoteAction = z.object({
|
||||
responseNoteId: ZId,
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZResolveResponseNoteAction = z.object({
|
||||
responseNoteId: ZId,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZCreateResponseNoteAction = z.object({
|
||||
responseId: ZId,
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetResponseAction = z.object({
|
||||
responseId: ZId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -8,10 +9,12 @@ export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||
authentication,
|
||||
parsedInput,
|
||||
request,
|
||||
auditLog,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
parsedInput: TInput;
|
||||
request: Request;
|
||||
auditLog?: ApiAuditLog;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export type ExtendedSchemas = {
|
||||
@@ -41,18 +44,25 @@ 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) {
|
||||
@@ -106,5 +116,6 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
authentication: authentication.data,
|
||||
parsedInput,
|
||||
request,
|
||||
auditLog,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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>({
|
||||
@@ -8,24 +10,35 @@ 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) {
|
||||
logApiRequest(request, response.status);
|
||||
if (auditLog) {
|
||||
auditLog.status = "success";
|
||||
}
|
||||
logApiRequest(request, response.status, auditLog);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -18,6 +18,9 @@ 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", () => {
|
||||
|
||||
30
apps/web/modules/api/v2/lib/utils-edge.ts
Normal file
30
apps/web/modules/api/v2/lib/utils-edge.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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");
|
||||
};
|
||||
@@ -1,14 +1,19 @@
|
||||
// @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 { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logApiErrorEdge } from "./utils-edge";
|
||||
|
||||
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
||||
logApiError(request, err);
|
||||
export const handleApiError = (
|
||||
request: Request,
|
||||
err: ApiErrorResponseV2,
|
||||
auditLog?: ApiAuditLog
|
||||
): Response => {
|
||||
logApiError(request, err, auditLog);
|
||||
|
||||
switch (err.type) {
|
||||
case "bad_request":
|
||||
@@ -50,7 +55,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
|
||||
});
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number): void => {
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -73,29 +78,22 @@ export const logApiRequest = (request: Request, responseStatus: number): void =>
|
||||
queryParams: safeQueryParams,
|
||||
})
|
||||
.info("API Request Details");
|
||||
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
|
||||
logApiErrorEdge(request, error);
|
||||
|
||||
// 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}`);
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
details: error.details,
|
||||
type: error.type,
|
||||
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"));
|
||||
}
|
||||
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
error,
|
||||
})
|
||||
.error("API Error Details");
|
||||
};
|
||||
|
||||
@@ -56,36 +56,55 @@ export const PUT = async (
|
||||
body: ZContactAttributeKeyUpdateSchema,
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
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);
|
||||
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "environment", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "environment", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.isUnique) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
|
||||
|
||||
if (!updatedContactAttributeKey.ok) {
|
||||
return handleApiError(request, updatedContactAttributeKey.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, updatedContactAttributeKey.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = res.data;
|
||||
auditLog.newObject = updatedContactAttributeKey.data;
|
||||
}
|
||||
|
||||
return responses.successResponse(updatedContactAttributeKey);
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "contactAttributeKey",
|
||||
});
|
||||
|
||||
export const DELETE = async (
|
||||
@@ -98,35 +117,53 @@ export const DELETE = async (
|
||||
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
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);
|
||||
return handleApiError(request, res.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "environment", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "environment", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.isUnique) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contactAttributeKey" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
|
||||
|
||||
if (!deletedContactAttributeKey.ok) {
|
||||
return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2);
|
||||
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 responses.successResponse(deletedContactAttributeKey);
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "contactAttributeKey",
|
||||
});
|
||||
|
||||
@@ -51,24 +51,35 @@ export const POST = async (request: NextRequest) =>
|
||||
schemas: {
|
||||
body: ZContactAttributeKeyInput,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
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" },
|
||||
],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
|
||||
],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const createContactAttributeKeyResult = await createContactAttributeKey(body);
|
||||
|
||||
if (!createContactAttributeKeyResult.ok) {
|
||||
return handleApiError(request, createContactAttributeKeyResult.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, createContactAttributeKeyResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createContactAttributeKeyResult.data.id;
|
||||
auditLog.newObject = createContactAttributeKeyResult.data;
|
||||
}
|
||||
|
||||
return responses.createdResponse(createContactAttributeKeyResult);
|
||||
},
|
||||
action: "created",
|
||||
targetType: "contactAttributeKey",
|
||||
});
|
||||
|
||||
@@ -59,35 +59,53 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
const { params } = parsedInput;
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = params.responseId;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.responseId, true);
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const response = await deleteResponse(params.responseId);
|
||||
|
||||
if (!response.ok) {
|
||||
return handleApiError(request, response.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, response.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = response.data;
|
||||
}
|
||||
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "response",
|
||||
});
|
||||
|
||||
export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
@@ -98,44 +116,56 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
body: ZResponseUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
const { body, params } = parsedInput;
|
||||
|
||||
if (!body || !params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.responseId, true);
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const existingResponse = await getResponse(params.responseId);
|
||||
|
||||
if (!existingResponse.ok) {
|
||||
return handleApiError(request, existingResponse.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, existingResponse.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
|
||||
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
|
||||
|
||||
if (!questionsResponse.ok) {
|
||||
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, questionsResponse.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
@@ -163,9 +193,16 @@ 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);
|
||||
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 responses.successResponse(response);
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "response",
|
||||
});
|
||||
|
||||
@@ -51,28 +51,36 @@ export const POST = async (request: Request) =>
|
||||
schemas: {
|
||||
body: ZResponseInput,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
const { body } = parsedInput;
|
||||
|
||||
if (!body) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "body", issue: "missing" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "body", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(body.surveyId, false);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
@@ -82,14 +90,18 @@ export const POST = async (request: Request) =>
|
||||
|
||||
const surveyQuestions = await getSurveyQuestions(body.surveyId);
|
||||
if (!surveyQuestions.ok) {
|
||||
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
@@ -116,9 +128,16 @@ export const POST = async (request: Request) =>
|
||||
|
||||
const createResponseResult = await createResponse(environmentId, body);
|
||||
if (!createResponseResult.ok) {
|
||||
return handleApiError(request, createResponseResult.error);
|
||||
return handleApiError(request, createResponseResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createResponseResult.data.id;
|
||||
auditLog.newObject = createResponseResult.data;
|
||||
}
|
||||
|
||||
return responses.createdResponse({ data: createResponseResult.data });
|
||||
},
|
||||
action: "created",
|
||||
targetType: "response",
|
||||
});
|
||||
|
||||
@@ -58,55 +58,77 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
body: ZWebhookUpdateSchema,
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
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" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
// get surveys environment
|
||||
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!surveysEnvironmentId.ok) {
|
||||
return handleApiError(request, surveysEnvironmentId.error);
|
||||
return handleApiError(request, surveysEnvironmentId.error, auditLog);
|
||||
}
|
||||
|
||||
// get webhook environment
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
if (!webhook.ok) {
|
||||
return handleApiError(request, webhook.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
// 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" },
|
||||
],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
|
||||
],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const updatedWebhook = await updateWebhook(params.webhookId, body);
|
||||
|
||||
if (!updatedWebhook.ok) {
|
||||
return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2);
|
||||
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 responses.successResponse(updatedWebhook);
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "webhook",
|
||||
});
|
||||
|
||||
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||
@@ -116,35 +138,52 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
const { params } = parsedInput;
|
||||
if (auditLog) {
|
||||
auditLog.targetId = params?.webhookId;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
if (!webhook.ok) {
|
||||
return handleApiError(request, webhook.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const deletedWebhook = await deleteWebhook(params.webhookId);
|
||||
|
||||
if (!deletedWebhook.ok) {
|
||||
return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2);
|
||||
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 responses.successResponse(deletedWebhook);
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "webhook",
|
||||
});
|
||||
|
||||
@@ -43,35 +43,50 @@ export const POST = async (request: NextRequest) =>
|
||||
schemas: {
|
||||
body: ZWebhookInput,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
handler: async ({ authentication, parsedInput, auditLog }) => {
|
||||
const { body } = parsedInput;
|
||||
|
||||
if (!body) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "body", issue: "missing" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "body", issue: "missing" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
return handleApiError(request, environmentIdResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
|
||||
return handleApiError(request, {
|
||||
type: "forbidden",
|
||||
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "forbidden",
|
||||
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const createWebhookResult = await createWebhook(body);
|
||||
|
||||
if (!createWebhookResult.ok) {
|
||||
return handleApiError(request, createWebhookResult.error);
|
||||
return handleApiError(request, createWebhookResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createWebhookResult.data.id;
|
||||
auditLog.newObject = createWebhookResult.data;
|
||||
}
|
||||
|
||||
return responses.createdResponse(createWebhookResult);
|
||||
},
|
||||
action: "created",
|
||||
targetType: "webhook",
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ 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,
|
||||
@@ -53,20 +55,28 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { body, params }, authentication }) => {
|
||||
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
|
||||
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" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
return handleApiError(request, hasAccess.error, auditLog);
|
||||
}
|
||||
|
||||
// check if project team already exists
|
||||
@@ -80,22 +90,32 @@ export async function POST(request: Request, props: { params: Promise<{ organiza
|
||||
});
|
||||
|
||||
if (!existingProjectTeam.ok) {
|
||||
return handleApiError(request, existingProjectTeam.error);
|
||||
return handleApiError(request, existingProjectTeam.error, auditLog);
|
||||
}
|
||||
|
||||
if (existingProjectTeam.data.data.length > 0) {
|
||||
return handleApiError(request, {
|
||||
type: "conflict",
|
||||
details: [{ field: "projectTeam", issue: "Project team already exists" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "conflict",
|
||||
details: [{ field: "projectTeam", issue: "Project team already exists" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
const result = await createProjectTeam(body!);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
return handleApiError(request, result.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.newObject = result.data;
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data });
|
||||
},
|
||||
action: "created",
|
||||
targetType: "projectTeam",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,29 +127,65 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { body, params }, authentication }) => {
|
||||
handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => {
|
||||
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" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
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`);
|
||||
}
|
||||
|
||||
const result = await updateProjectTeam(teamId, projectId, body!);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
return handleApiError(request, result.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = oldProjectTeamData;
|
||||
auditLog.newObject = result.data;
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data });
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "projectTeam",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,28 +197,63 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { query, params }, authentication }) => {
|
||||
handler: async ({ parsedInput: { query, params }, authentication, auditLog }) => {
|
||||
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" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
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`);
|
||||
}
|
||||
|
||||
const result = await deleteProjectTeam(teamId, projectId);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
return handleApiError(request, result.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = oldProjectTeamData;
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data });
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "projectTeam",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ 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 (
|
||||
@@ -53,22 +55,46 @@ export const DELETE = async (
|
||||
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
handler: async ({ authentication, parsedInput: { params }, auditLog }) => {
|
||||
if (auditLog) {
|
||||
auditLog.targetId = params.teamId;
|
||||
}
|
||||
|
||||
const team = await deleteTeam(params!.organizationId, params!.teamId);
|
||||
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);
|
||||
|
||||
if (!team.ok) {
|
||||
return handleApiError(request, team.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, team.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = oldTeamData;
|
||||
}
|
||||
|
||||
return responses.successResponse(team);
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "team",
|
||||
});
|
||||
|
||||
export const PUT = (
|
||||
@@ -82,20 +108,45 @@ export const PUT = (
|
||||
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
|
||||
body: ZTeamUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
|
||||
if (auditLog) {
|
||||
auditLog.targetId = params.teamId;
|
||||
}
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
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 updateTeam(params!.organizationId, params!.teamId, body!);
|
||||
|
||||
if (!team.ok) {
|
||||
return handleApiError(request, team.error as ApiErrorResponseV2);
|
||||
return handleApiError(request, team.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.oldObject = oldTeamData;
|
||||
auditLog.newObject = team.data;
|
||||
}
|
||||
|
||||
return responses.successResponse(team);
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "team",
|
||||
});
|
||||
|
||||
@@ -46,19 +46,30 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const createTeamResult = await createTeam(body!, authentication.organizationId);
|
||||
if (!createTeamResult.ok) {
|
||||
return handleApiError(request, createTeamResult.error);
|
||||
return handleApiError(request, createTeamResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createTeamResult.data.id;
|
||||
auditLog.newObject = createTeamResult.data;
|
||||
}
|
||||
|
||||
return responses.createdResponse({ data: createTeamResult.data });
|
||||
},
|
||||
action: "created",
|
||||
targetType: "team",
|
||||
});
|
||||
|
||||
@@ -14,8 +14,10 @@ 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 }> }) =>
|
||||
@@ -59,28 +61,45 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
|
||||
],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const createUserResult = await createUser(body!, authentication.organizationId);
|
||||
if (!createUserResult.ok) {
|
||||
return handleApiError(request, createUserResult.error);
|
||||
return handleApiError(request, createUserResult.error, auditLog);
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = createUserResult.data.id;
|
||||
auditLog.newObject = createUserResult.data;
|
||||
}
|
||||
|
||||
return responses.createdResponse({ data: createUserResult.data });
|
||||
},
|
||||
action: "created",
|
||||
targetType: "user",
|
||||
});
|
||||
|
||||
export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
|
||||
@@ -91,33 +110,75 @@ export const PATCH = async (request: Request, props: { params: Promise<{ organiz
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" },
|
||||
],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (!body?.email) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "email", issue: "Email is required" }],
|
||||
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",
|
||||
});
|
||||
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);
|
||||
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 responses.successResponse({ data: updateUserResult.data });
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "user",
|
||||
});
|
||||
|
||||
149
apps/web/modules/auth/actions/sign-out.test.ts
Normal file
149
apps/web/modules/auth/actions/sign-out.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
32
apps/web/modules/auth/actions/sign-out.ts
Normal file
32
apps/web/modules/auth/actions/sign-out.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"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;
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,9 @@
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { updateUser } from "@/modules/auth/lib/user";
|
||||
import { getUser } from "@/modules/auth/lib/user";
|
||||
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getUser, updateUser } from "@/modules/auth/lib/user";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -15,16 +16,25 @@ const ZResetPasswordAction = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
export const resetPasswordAction = actionClient
|
||||
.schema(ZResetPasswordAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const { id } = await verifyToken(parsedInput.token);
|
||||
const user = await getUser(id);
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", id);
|
||||
export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const { id } = await verifyToken(parsedInput.token);
|
||||
const oldObject = await getUser(id);
|
||||
if (!oldObject) {
|
||||
throw new ResourceNotFoundError("user", id);
|
||||
}
|
||||
const updatedUser = await updateUser(id, { password: hashedPassword });
|
||||
|
||||
ctx.auditLoggingCtx.userId = id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = updatedUser;
|
||||
|
||||
await sendPasswordResetNotifyEmail(updatedUser);
|
||||
return { success: true };
|
||||
}
|
||||
const updatedUser = await updateUser(id, { password: hashedPassword });
|
||||
await sendPasswordResetNotifyEmail(updatedUser);
|
||||
return { success: true };
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
251
apps/web/modules/auth/hooks/use-sign-out.test.tsx
Normal file
251
apps/web/modules/auth/hooks/use-sign-out.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, renderHook } from "@testing-library/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
// Import the actual hook (unmock it for testing)
|
||||
vi.unmock("@/modules/auth/hooks/use-sign-out");
|
||||
const { useSignOut } = await import("./use-sign-out");
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useSignOut", () => {
|
||||
const mockSessionUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return signOut function", () => {
|
||||
const { result } = renderHook(() => useSignOut());
|
||||
|
||||
expect(result.current.signOut).toBeDefined();
|
||||
expect(typeof result.current.signOut).toBe("function");
|
||||
});
|
||||
|
||||
test("should sign out without audit logging when no session user", async () => {
|
||||
const { result } = renderHook(() => useSignOut());
|
||||
|
||||
await result.current.signOut();
|
||||
|
||||
expect(logSignOutAction).not.toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: undefined,
|
||||
callbackUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should sign out with audit logging when session user exists", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/dashboard",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/dashboard",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: undefined,
|
||||
callbackUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle null session user", async () => {
|
||||
const { result } = renderHook(() => useSignOut(null));
|
||||
|
||||
await result.current.signOut();
|
||||
|
||||
expect(logSignOutAction).not.toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: undefined,
|
||||
callbackUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should use default reason when not provided", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut();
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
|
||||
reason: "user_initiated",
|
||||
redirectUrl: undefined,
|
||||
organizationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should use callbackUrl as redirectUrl when redirectUrl not provided", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut({
|
||||
callbackUrl: "/auth/login",
|
||||
organizationId: "org-456",
|
||||
});
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org-456",
|
||||
});
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: undefined,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass through NextAuth signOut options", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut({
|
||||
redirect: false,
|
||||
callbackUrl: "/custom-redirect",
|
||||
});
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: false,
|
||||
callbackUrl: "/custom-redirect",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle different sign out reasons", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
const reasons = ["account_deletion", "email_change", "session_timeout", "forced_logout"] as const;
|
||||
|
||||
for (const reason of reasons) {
|
||||
vi.clearAllMocks();
|
||||
|
||||
await result.current.signOut({ reason });
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
|
||||
reason,
|
||||
redirectUrl: undefined,
|
||||
organizationId: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle session user without email", async () => {
|
||||
const userWithoutEmail = { id: "user-456" };
|
||||
const { result } = renderHook(() => useSignOut(userWithoutEmail));
|
||||
|
||||
await result.current.signOut();
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-456", "", {
|
||||
reason: "user_initiated",
|
||||
redirectUrl: undefined,
|
||||
organizationId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not block sign out when audit logging fails", async () => {
|
||||
vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Audit logging failed"));
|
||||
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to log signOut event:", expect.any(Error));
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: undefined,
|
||||
callbackUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return NextAuth signOut result", async () => {
|
||||
const mockSignOutResult = { url: "https://example.com/signed-out" };
|
||||
vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult);
|
||||
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
const signOutResult = await result.current.signOut();
|
||||
|
||||
expect(signOutResult).toBe(mockSignOutResult);
|
||||
});
|
||||
|
||||
test("should handle audit logging error and still return NextAuth result", async () => {
|
||||
const mockSignOutResult = { url: "https://example.com/signed-out" };
|
||||
vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Network error"));
|
||||
vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult);
|
||||
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
const signOutResult = await result.current.signOut();
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(signOutResult).toBe(mockSignOutResult);
|
||||
});
|
||||
|
||||
test("should handle complex sign out scenario", async () => {
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
await result.current.signOut({
|
||||
reason: "email_change",
|
||||
redirectUrl: "/profile/email-changed",
|
||||
organizationId: "org-complex-123",
|
||||
redirect: true,
|
||||
callbackUrl: "/dashboard",
|
||||
});
|
||||
|
||||
expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", {
|
||||
reason: "email_change",
|
||||
redirectUrl: "/profile/email-changed", // redirectUrl takes precedence over callbackUrl
|
||||
organizationId: "org-complex-123",
|
||||
});
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({
|
||||
redirect: true,
|
||||
callbackUrl: "/dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
test("should wait for audit logging before calling NextAuth signOut", async () => {
|
||||
let auditLogResolved = false;
|
||||
vi.mocked(logSignOutAction).mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
auditLogResolved = true;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSignOut(mockSessionUser));
|
||||
|
||||
const signOutPromise = result.current.signOut();
|
||||
|
||||
// NextAuth signOut should not be called immediately
|
||||
expect(signOut).not.toHaveBeenCalled();
|
||||
|
||||
await signOutPromise;
|
||||
|
||||
expect(auditLogResolved).toBe(true);
|
||||
expect(signOut).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
47
apps/web/modules/auth/hooks/use-sign-out.ts
Normal file
47
apps/web/modules/auth/hooks/use-sign-out.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface UseSignOutOptions {
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
redirect?: boolean;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
|
||||
interface SessionUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to handle sign out with audit logging
|
||||
* @param sessionUser - The current user session data (optional)
|
||||
* @returns {Object} - An object containing the signOutWithAudit function
|
||||
*/
|
||||
export const useSignOut = (sessionUser?: SessionUser | null) => {
|
||||
const signOutWithAudit = async (options?: UseSignOutOptions) => {
|
||||
// Log audit event before signing out (server action)
|
||||
if (sessionUser?.id) {
|
||||
try {
|
||||
await logSignOutAction(sessionUser.id, sessionUser.email ?? "", {
|
||||
reason: options?.reason || "user_initiated", // NOSONAR // We want to check for empty strings
|
||||
redirectUrl: options?.redirectUrl || options?.callbackUrl, // NOSONAR // We want to check for empty strings
|
||||
organizationId: options?.organizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't block signOut if audit logging fails
|
||||
logger.error("Failed to log signOut event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Call NextAuth signOut
|
||||
return await signOut({
|
||||
redirect: options?.redirect,
|
||||
callbackUrl: options?.callbackUrl,
|
||||
});
|
||||
};
|
||||
|
||||
return { signOut: signOutWithAudit };
|
||||
};
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -8,8 +8,31 @@ import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
}));
|
||||
|
||||
// Mock next/headers
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: () => ({
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
keys: () => [],
|
||||
values: () => [],
|
||||
entries: () => [],
|
||||
forEach: () => {},
|
||||
}),
|
||||
cookies: () => ({
|
||||
get: (name: string) => {
|
||||
if (name === "next-auth.callback-url") {
|
||||
@@ -73,7 +96,7 @@ describe("authOptions", () => {
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
password: null,
|
||||
});
|
||||
} as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
@@ -87,7 +110,7 @@ describe("authOptions", () => {
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
password: mockHashedPassword,
|
||||
});
|
||||
} as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: "wrongPassword" };
|
||||
|
||||
@@ -106,7 +129,7 @@ describe("authOptions", () => {
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
@@ -128,7 +151,7 @@ describe("authOptions", () => {
|
||||
twoFactorEnabled: true,
|
||||
backupCodes: null,
|
||||
};
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword, backupCode: "123456" };
|
||||
|
||||
@@ -157,7 +180,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if email is already verified", async () => {
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
|
||||
@@ -167,7 +190,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should update user and verify email when token is valid", async () => {
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null });
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
@@ -175,7 +198,7 @@ describe("authOptions", () => {
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
});
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
@@ -193,7 +216,7 @@ describe("authOptions", () => {
|
||||
locale: mockUser.locale,
|
||||
email: mockUser.email,
|
||||
emailVerified: mockUser.emailVerified,
|
||||
});
|
||||
} as any);
|
||||
|
||||
const token = { email: mockUser.email };
|
||||
if (!authOptions.callbacks?.jwt) {
|
||||
@@ -259,7 +282,7 @@ describe("authOptions", () => {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: "encrypted_secret",
|
||||
};
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
@@ -276,7 +299,7 @@ describe("authOptions", () => {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: null,
|
||||
};
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = {
|
||||
email: mockUser.email,
|
||||
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import {
|
||||
logAuthAttempt,
|
||||
logAuthEvent,
|
||||
logAuthSuccess,
|
||||
logEmailVerificationAttempt,
|
||||
logTwoFactorAttempt,
|
||||
shouldLogAuthFailure,
|
||||
verifyPassword,
|
||||
} from "@/modules/auth/lib/utils";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
|
||||
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
import type { Account, NextAuthOptions } from "next-auth";
|
||||
@@ -43,9 +52,16 @@ export const authOptions: NextAuthOptions = {
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
|
||||
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
|
||||
|
||||
if (!credentials) {
|
||||
if (await shouldLogAuthFailure("no_credentials")) {
|
||||
logAuthAttempt("no_credentials_provided", "credentials", "credentials_validation");
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
@@ -55,37 +71,70 @@ export const authOptions: NextAuthOptions = {
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e, "Error in CredentialsProvider authorize");
|
||||
logAuthAttempt("database_error", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
|
||||
throw Error("Internal server error. Please try again later");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
|
||||
throw new Error("User has no password stored");
|
||||
}
|
||||
|
||||
if (user.isActive === false) {
|
||||
logAuthAttempt("account_inactive", "credentials", "account_status", user.id, user.email);
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
logAuthSuccess("passwordVerified", "credentials", "password_validation", user.id, user.email, {
|
||||
requires2FA: user.twoFactorEnabled,
|
||||
});
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "encryption_key_missing");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error("No backup codes found");
|
||||
if (!user.backupCodes) {
|
||||
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "no_backup_codes");
|
||||
throw new Error("No backup codes found");
|
||||
}
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
let backupCodes;
|
||||
|
||||
try {
|
||||
backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
} catch (e) {
|
||||
logger.error(e, "Error in CredentialsProvider authorize");
|
||||
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_codes");
|
||||
throw new Error("Invalid backup codes");
|
||||
}
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error("Invalid backup code");
|
||||
if (index === -1) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_code");
|
||||
}
|
||||
throw new Error("Invalid backup code");
|
||||
}
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
@@ -97,30 +146,58 @@ export const authOptions: NextAuthOptions = {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
|
||||
logTwoFactorAttempt(true, "backup_code", user.id, user.email, undefined, {
|
||||
backupCodeConsumed: true,
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
logAuthEvent("twoFactorRequired", "success", user.id, user.email, {
|
||||
provider: "credentials",
|
||||
authMethod: "password_validation",
|
||||
requiresTOTP: true,
|
||||
});
|
||||
throw new Error("second factor required");
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
logTwoFactorAttempt(false, "totp", user.id, user.email, "no_2fa_secret");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logTwoFactorAttempt(false, "totp", user.id, user.email, "encryption_key_missing");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_2fa_secret");
|
||||
throw new Error("Invalid two factor secret");
|
||||
}
|
||||
|
||||
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_totp_code");
|
||||
}
|
||||
throw new Error("Invalid two factor code");
|
||||
}
|
||||
|
||||
logTwoFactorAttempt(true, "totp", user.id, user.email);
|
||||
}
|
||||
|
||||
let authMethod;
|
||||
if (!user.twoFactorEnabled) {
|
||||
authMethod = "password_only";
|
||||
} else if (credentials.backupCode) {
|
||||
authMethod = "password_and_backup_code";
|
||||
} else {
|
||||
authMethod = "password_and_totp";
|
||||
}
|
||||
|
||||
logAuthSuccess("authenticationSucceeded", "credentials", authMethod, user.id, user.email);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -144,11 +221,19 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
// For token verification, we can't rate limit effectively by token (single-use)
|
||||
// So we use a generic identifier for token abuse attempts
|
||||
const identifier = "email_verification_attempts";
|
||||
|
||||
let user;
|
||||
try {
|
||||
if (!credentials?.token) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logEmailVerificationAttempt(false, "token_not_provided");
|
||||
}
|
||||
throw new Error("Token not found");
|
||||
}
|
||||
|
||||
const { id } = await verifyToken(credentials?.token);
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
@@ -156,23 +241,39 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e, "Error in CredentialsProvider authorize");
|
||||
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logEmailVerificationAttempt(false, "invalid_token", UNKNOWN_DATA, undefined, {
|
||||
tokenProvided: !!credentials?.token,
|
||||
});
|
||||
}
|
||||
throw new Error("Either a user does not match the provided token or the token is invalid");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logEmailVerificationAttempt(false, "user_not_found_for_token");
|
||||
}
|
||||
throw new Error("Either a user does not match the provided token or the token is invalid");
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
|
||||
throw new Error("Email already verified");
|
||||
}
|
||||
|
||||
if (user.isActive === false) {
|
||||
logEmailVerificationAttempt(false, "account_inactive", user.id, user.email);
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
user = await updateUser(user.id, { emailVerified: new Date() });
|
||||
|
||||
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
|
||||
emailVerifiedAt: user.emailVerified,
|
||||
});
|
||||
|
||||
// send new user to brevo after email verification
|
||||
createBrevoCustomer({ id: user.id, email: user.email });
|
||||
|
||||
|
||||
@@ -1,38 +1,401 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashPassword, verifyPassword } from "./utils";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createAuditIdentifier,
|
||||
hashPassword,
|
||||
logAuthAttempt,
|
||||
logAuthEvent,
|
||||
logAuthSuccess,
|
||||
logEmailVerificationAttempt,
|
||||
logSignOut,
|
||||
logTwoFactorAttempt,
|
||||
shouldLogAuthFailure,
|
||||
verifyPassword,
|
||||
} from "./utils";
|
||||
|
||||
describe("Password Utils", () => {
|
||||
const password = "password";
|
||||
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
|
||||
// Mock the audit event handler
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("hashPassword", () => {
|
||||
test("should hash a password", async () => {
|
||||
const hashedPassword = await hashPassword(password);
|
||||
// Mock crypto for consistent hash testing
|
||||
vi.mock("crypto", () => ({
|
||||
createHash: vi.fn(() => ({
|
||||
update: vi.fn(() => ({
|
||||
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
expect(typeof hashedPassword).toBe("string");
|
||||
expect(hashedPassword).not.toBe(password);
|
||||
expect(hashedPassword.length).toBe(60);
|
||||
});
|
||||
// Mock Sentry
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
test("should generate different hashes for the same password", async () => {
|
||||
const hash1 = await hashPassword(password);
|
||||
const hash2 = await hashPassword(password);
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "test-sentry-dsn",
|
||||
IS_PRODUCTION: true,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
// Mock Redis client
|
||||
vi.mock("@/modules/cache/redis", () => ({
|
||||
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
|
||||
}));
|
||||
|
||||
describe("Auth Utils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Password Utils", () => {
|
||||
const password = "password";
|
||||
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
|
||||
|
||||
test("should hash a password", async () => {
|
||||
const newHashedPassword = await hashPassword(password);
|
||||
|
||||
expect(typeof newHashedPassword).toBe("string");
|
||||
expect(newHashedPassword).not.toBe(password);
|
||||
expect(newHashedPassword.length).toBe(60);
|
||||
});
|
||||
|
||||
test("should verify a correct password", async () => {
|
||||
const isValid = await verifyPassword(password, hashedPassword);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject an incorrect password", async () => {
|
||||
const isValid = await verifyPassword("WrongPassword123!", hashedPassword);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Identifier Utils", () => {
|
||||
test("should create a hashed identifier for email", () => {
|
||||
const email = "user@example.com";
|
||||
const identifier = createAuditIdentifier(email, "email");
|
||||
|
||||
expect(identifier).toBe("email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
expect(identifier).not.toContain("user@example.com");
|
||||
});
|
||||
|
||||
test("should return unknown for empty/unknown identifiers", () => {
|
||||
expect(createAuditIdentifier("")).toBe("unknown");
|
||||
expect(createAuditIdentifier("unknown")).toBe("unknown");
|
||||
expect(createAuditIdentifier("unknown_user")).toBe("unknown");
|
||||
});
|
||||
|
||||
test("should create consistent hashes for same input", () => {
|
||||
const email = "test@example.com";
|
||||
const id1 = createAuditIdentifier(email, "email");
|
||||
const id2 = createAuditIdentifier(email, "email");
|
||||
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test("should use default prefix when none provided", () => {
|
||||
const identifier = createAuditIdentifier("test@example.com");
|
||||
expect(identifier).toMatch(/^actor_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should always allow successful authentication logging", async () => {
|
||||
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
||||
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
||||
});
|
||||
|
||||
test("should implement fail-closed behavior when Redis is unavailable", async () => {
|
||||
const email = "rate-limit-test@example.com";
|
||||
|
||||
// When Redis is unavailable (mocked as null), the system fails closed for security.
|
||||
// This prevents authentication failure logging when we cannot enforce rate limiting,
|
||||
// ensuring consistent security posture across distributed systems.
|
||||
// All authentication failure attempts should return false (do not log).
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 1st failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 2nd failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 3rd failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 4th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 5th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 6th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 7th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 8th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Logging Functions", () => {
|
||||
test("should log auth event with hashed identifier", () => {
|
||||
logAuthEvent("authenticationAttempted", "failure", "unknown", "user@example.com", {
|
||||
failureReason: "invalid_password",
|
||||
});
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "authenticationAttempted",
|
||||
targetType: "user",
|
||||
userId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
targetId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
failureReason: "invalid_password",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should use provided userId when available", () => {
|
||||
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
|
||||
requires2FA: true,
|
||||
});
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "passwordVerified",
|
||||
targetType: "user",
|
||||
userId: "user_123",
|
||||
targetId: "user_123",
|
||||
organizationId: "unknown",
|
||||
status: "success",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
requires2FA: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should log authentication attempt with correct structure", () => {
|
||||
logAuthAttempt(
|
||||
"invalid_password",
|
||||
"credentials",
|
||||
"password_validation",
|
||||
"user_123",
|
||||
"user@example.com"
|
||||
);
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "authenticationAttempted",
|
||||
status: "failure",
|
||||
userId: "user_123",
|
||||
newObject: expect.objectContaining({
|
||||
failureReason: "invalid_password",
|
||||
provider: "credentials",
|
||||
authMethod: "password_validation",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should log successful authentication", () => {
|
||||
logAuthSuccess(
|
||||
"authenticationSucceeded",
|
||||
"credentials",
|
||||
"password_only",
|
||||
"user_123",
|
||||
"user@example.com"
|
||||
);
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "authenticationSucceeded",
|
||||
status: "success",
|
||||
userId: "user_123",
|
||||
newObject: expect.objectContaining({
|
||||
provider: "credentials",
|
||||
authMethod: "password_only",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should log two-factor verification", () => {
|
||||
logTwoFactorAttempt(true, "totp", "user_123", "user@example.com");
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "twoFactorVerified",
|
||||
status: "success",
|
||||
newObject: expect.objectContaining({
|
||||
provider: "credentials",
|
||||
authMethod: "totp",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should log failed two-factor attempt", () => {
|
||||
logTwoFactorAttempt(false, "backup_code", "user_123", "user@example.com", "invalid_backup_code");
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "twoFactorAttempted",
|
||||
status: "failure",
|
||||
newObject: expect.objectContaining({
|
||||
provider: "credentials",
|
||||
authMethod: "backup_code",
|
||||
failureReason: "invalid_backup_code",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should log email verification", () => {
|
||||
logEmailVerificationAttempt(true, undefined, "user_123", "user@example.com", {
|
||||
emailVerifiedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "emailVerified",
|
||||
status: "success",
|
||||
newObject: expect.objectContaining({
|
||||
provider: "token",
|
||||
authMethod: "email_verification",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should log failed email verification", () => {
|
||||
logEmailVerificationAttempt(false, "invalid_token", "user_123", "user@example.com", {
|
||||
tokenProvided: true,
|
||||
});
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "emailVerificationAttempted",
|
||||
targetType: "user",
|
||||
userId: "user_123",
|
||||
userType: "user",
|
||||
targetId: "user_123",
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "failure",
|
||||
newObject: {
|
||||
failureReason: "invalid_token",
|
||||
provider: "token",
|
||||
authMethod: "email_verification",
|
||||
tokenProvided: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should log user sign out event", () => {
|
||||
logSignOut("user_123", "user@example.com", {
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org_123",
|
||||
});
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "userSignedOut",
|
||||
targetType: "user",
|
||||
userId: "user_123",
|
||||
userType: "user",
|
||||
targetId: "user_123",
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "success",
|
||||
newObject: {
|
||||
provider: "session",
|
||||
authMethod: "sign_out",
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org_123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should log sign out with default reason", () => {
|
||||
logSignOut("user_123", "user@example.com");
|
||||
|
||||
expect(queueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "userSignedOut",
|
||||
targetType: "user",
|
||||
userId: "user_123",
|
||||
userType: "user",
|
||||
targetId: "user_123",
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "success",
|
||||
newObject: {
|
||||
provider: "session",
|
||||
authMethod: "sign_out",
|
||||
reason: "user_initiated",
|
||||
organizationId: undefined,
|
||||
redirectUrl: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PII Protection", () => {
|
||||
test("should never log actual email addresses", () => {
|
||||
const email = "sensitive@company.com";
|
||||
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
|
||||
|
||||
const logCall = (queueAuditEventBackground as any).mock.calls[0][0];
|
||||
const logString = JSON.stringify(logCall);
|
||||
|
||||
expect(logString).not.toContain("sensitive@company.com");
|
||||
expect(logString).not.toContain("company.com");
|
||||
expect(logString).not.toContain("sensitive");
|
||||
});
|
||||
|
||||
test("should create consistent hashed identifiers", () => {
|
||||
const email = "user@example.com";
|
||||
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email);
|
||||
logAuthAttempt("user_not_found", "credentials", "user_lookup", "unknown", email);
|
||||
|
||||
const calls = (queueAuditEventBackground as any).mock.calls;
|
||||
expect(calls[0][0].userId).toBe(calls[1][0].userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sentry Integration", () => {
|
||||
test("should capture authentication failures to Sentry", () => {
|
||||
logAuthEvent("authenticationAttempted", "failure", "user_123", "user@example.com", {
|
||||
failureReason: "invalid_password",
|
||||
provider: "credentials",
|
||||
authMethod: "password_validation",
|
||||
tags: { security_event: "password_failure" },
|
||||
});
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
component: "authentication",
|
||||
action: "authenticationAttempted",
|
||||
status: "failure",
|
||||
security_event: "password_failure",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
userId: "user_123",
|
||||
provider: "credentials",
|
||||
authMethod: "password_validation",
|
||||
failureReason: "invalid_password",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should not capture successful authentication to Sentry", () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", {
|
||||
provider: "credentials",
|
||||
authMethod: "password_validation",
|
||||
});
|
||||
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import redis from "@/modules/cache/redis";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const hashPassword = async (password: string) => {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
@@ -9,3 +16,283 @@ export const verifyPassword = async (password: string, hashedPassword: string) =
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a consistent hashed identifier for audit logging that protects PII
|
||||
* while still allowing pattern tracking and rate limiting.
|
||||
*
|
||||
* @param identifier - The identifier to hash (email, IP, etc.)
|
||||
* @param prefix - Optional prefix for the hash (e.g., "email", "ip")
|
||||
* @returns A consistent SHA-256 hash that can be used for tracking without exposing PII
|
||||
*/
|
||||
export const createAuditIdentifier = (identifier: string, prefix: string = "actor"): string => {
|
||||
if (!identifier || identifier === "unknown" || identifier === "unknown_user") {
|
||||
return UNKNOWN_DATA;
|
||||
}
|
||||
|
||||
// Create a consistent hash that can be used for pattern detection
|
||||
// Use a longer hash for better collision resistance in compliance scenarios
|
||||
const hash = createHash("sha256").update(identifier.toLowerCase()).digest("hex");
|
||||
return `${prefix}_${hash.substring(0, 32)}`; // Use first 32 chars for better uniqueness
|
||||
};
|
||||
|
||||
export const logAuthEvent = (
|
||||
action: TAuditAction,
|
||||
status: TAuditStatus,
|
||||
userId: string,
|
||||
email?: string,
|
||||
additionalData: Record<string, any> = {}
|
||||
) => {
|
||||
const auditActorId = userId === UNKNOWN_DATA && email ? createAuditIdentifier(email, "email") : userId;
|
||||
|
||||
// Log failures to Sentry for monitoring and alerting
|
||||
if (status === "failure" && SENTRY_DSN && IS_PRODUCTION) {
|
||||
const error = new Error(`Authentication ${action} failed`);
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
component: "authentication",
|
||||
action,
|
||||
status,
|
||||
...(additionalData.tags ?? {}),
|
||||
},
|
||||
extra: {
|
||||
userId: auditActorId,
|
||||
provider: additionalData.provider,
|
||||
authMethod: additionalData.authMethod,
|
||||
failureReason: additionalData.failureReason,
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
queueAuditEventBackground({
|
||||
action,
|
||||
targetType: "user",
|
||||
userId: auditActorId,
|
||||
targetId: auditActorId,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status,
|
||||
userType: "user",
|
||||
newObject: {
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function for logging authentication attempts with consistent failure reasons.
|
||||
*
|
||||
* @param failureReason - Specific reason for authentication failure
|
||||
* @param provider - Authentication provider (credentials, token, etc.)
|
||||
* @param authMethod - Authentication method (password, totp, backup_code, etc.)
|
||||
* @param userId - User ID (use UNKNOWN_DATA if not available)
|
||||
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
|
||||
* @param additionalData - Additional context data
|
||||
*/
|
||||
export const logAuthAttempt = (
|
||||
failureReason: string,
|
||||
provider: string,
|
||||
authMethod: string,
|
||||
userId: string = UNKNOWN_DATA,
|
||||
email?: string,
|
||||
additionalData: Record<string, any> = {}
|
||||
) => {
|
||||
logAuthEvent("authenticationAttempted", "failure", userId, email, {
|
||||
failureReason,
|
||||
provider,
|
||||
authMethod,
|
||||
...additionalData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function for logging successful authentication events.
|
||||
*
|
||||
* @param action - The specific success action (passwordVerified, twoFactorVerified, etc.)
|
||||
* @param provider - Authentication provider
|
||||
* @param authMethod - Authentication method
|
||||
* @param userId - User ID
|
||||
* @param email - User email - used ONLY to create hashed identifier, never stored
|
||||
* @param additionalData - Additional context data
|
||||
*/
|
||||
export const logAuthSuccess = (
|
||||
action: TAuditAction,
|
||||
provider: string,
|
||||
authMethod: string,
|
||||
userId: string,
|
||||
email: string,
|
||||
additionalData: Record<string, any> = {}
|
||||
) => {
|
||||
logAuthEvent(action, "success", userId, email, {
|
||||
provider,
|
||||
authMethod,
|
||||
...additionalData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function for logging two-factor authentication attempts.
|
||||
*
|
||||
* @param isSuccess - Whether the 2FA attempt was successful
|
||||
* @param authMethod - 2FA method (totp, backup_code)
|
||||
* @param userId - User ID
|
||||
* @param email - User email - used ONLY to create hashed identifier, never stored
|
||||
* @param failureReason - Failure reason (only for failed attempts)
|
||||
* @param additionalData - Additional context data
|
||||
*/
|
||||
export const logTwoFactorAttempt = (
|
||||
isSuccess: boolean,
|
||||
authMethod: string,
|
||||
userId: string,
|
||||
email: string,
|
||||
failureReason?: string,
|
||||
additionalData: Record<string, any> = {}
|
||||
) => {
|
||||
const action = isSuccess ? "twoFactorVerified" : "twoFactorAttempted";
|
||||
const status = isSuccess ? "success" : "failure";
|
||||
|
||||
logAuthEvent(action, status, userId, email, {
|
||||
provider: "credentials",
|
||||
authMethod,
|
||||
...(failureReason && !isSuccess ? { failureReason } : {}),
|
||||
...additionalData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function for logging email verification attempts.
|
||||
*
|
||||
* @param isSuccess - Whether the verification was successful
|
||||
* @param failureReason - Failure reason (only for failed attempts)
|
||||
* @param userId - User ID (use UNKNOWN_DATA if not available)
|
||||
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
|
||||
* @param additionalData - Additional context data
|
||||
*/
|
||||
export const logEmailVerificationAttempt = (
|
||||
isSuccess: boolean,
|
||||
failureReason?: string,
|
||||
userId: string = UNKNOWN_DATA,
|
||||
email?: string,
|
||||
additionalData: Record<string, any> = {}
|
||||
) => {
|
||||
const action = isSuccess ? "emailVerified" : "emailVerificationAttempted";
|
||||
const status = isSuccess ? "success" : "failure";
|
||||
|
||||
logAuthEvent(action, status, userId, email, {
|
||||
provider: "token",
|
||||
authMethod: "email_verification",
|
||||
...(failureReason && !isSuccess ? { failureReason } : {}),
|
||||
...additionalData,
|
||||
});
|
||||
};
|
||||
|
||||
// Rate limiting constants
|
||||
const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes
|
||||
const AGGREGATION_THRESHOLD = 3; // After 3 failures, start aggregating
|
||||
|
||||
/**
|
||||
* Rate limiting decision function for authentication audit logs.
|
||||
* Uses Redis for distributed rate limiting across Kubernetes pods.
|
||||
*
|
||||
* **What this function does:**
|
||||
* - Returns true/false to indicate whether an auth attempt should be logged
|
||||
* - Always returns true for successful authentications (no rate limiting)
|
||||
* - For failures: allows first 3 attempts per identifier within 5-minute window
|
||||
* - After 3 failures: allows every 10th attempt OR after 1+ minute gap
|
||||
* - Uses hashed identifiers to protect PII while enabling tracking
|
||||
* - Returns false if Redis is unavailable (fail closed)
|
||||
*
|
||||
* **Use cases:**
|
||||
* - Gate authentication failure logging to prevent spam
|
||||
* - Provide consistent rate limiting decisions across Kubernetes pods
|
||||
* - Protect user PII through identifier hashing
|
||||
*
|
||||
* **Example usage:**
|
||||
* ```typescript
|
||||
* if (await shouldLogAuthFailure(user.email)) {
|
||||
* logAuthAttempt("invalid_password", "credentials", "password", user.id, user.email);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param identifier - Unique identifier for rate limiting (email, token, etc.) - will be hashed
|
||||
* @param isSuccess - Whether this is a successful authentication (defaults to false)
|
||||
* @returns Promise<boolean> - Whether this attempt should be logged to audit trail
|
||||
*/
|
||||
export const shouldLogAuthFailure = async (
|
||||
identifier: string,
|
||||
isSuccess: boolean = false
|
||||
): Promise<boolean> => {
|
||||
// Always log successful authentications
|
||||
if (isSuccess) return true;
|
||||
|
||||
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
// Use Redis for distributed rate limiting
|
||||
const multi = redis.multi();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW;
|
||||
|
||||
// Remove expired entries and count recent failures
|
||||
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
|
||||
multi.zcard(rateLimitKey);
|
||||
multi.zadd(rateLimitKey, now, `${now}:${randomUUID()}`);
|
||||
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
|
||||
|
||||
const results = await multi.exec();
|
||||
if (!results) {
|
||||
throw new Error("Redis transaction failed");
|
||||
}
|
||||
|
||||
const currentCount = results[1][1] as number;
|
||||
|
||||
// Apply throttling logic
|
||||
if (currentCount <= AGGREGATION_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we should log (every 10th or after 1 minute gap)
|
||||
const recentEntries = await redis.zrange(rateLimitKey, -10, -1);
|
||||
if (recentEntries.length === 0) return true;
|
||||
|
||||
const lastLogTime = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
|
||||
const timeSinceLastLog = now - lastLogTime;
|
||||
|
||||
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
|
||||
} catch (error) {
|
||||
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
|
||||
// If Redis fails, do not log as Redis is required for audit logs
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
|
||||
// If Redis not configured, do not log as Redis is required for audit logs
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs a user sign out event for audit compliance.
|
||||
*
|
||||
* @param userId - The ID of the user signing out
|
||||
* @param userEmail - The email of the user signing out
|
||||
* @param context - Additional context about the sign out (reason, redirect URL, etc.)
|
||||
*/
|
||||
export const logSignOut = (
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
context?: {
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
) => {
|
||||
logAuthEvent("userSignedOut", "success", userId, userEmail, {
|
||||
provider: "session",
|
||||
authMethod: "sign_out",
|
||||
reason: context?.reason || "user_initiated", // NOSONAR // We want to check for empty strings
|
||||
redirectUrl: context?.redirectUrl,
|
||||
organizationId: context?.organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,10 +6,12 @@ import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { createUser, updateUser } from "@/modules/auth/lib/user";
|
||||
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
|
||||
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
|
||||
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
@@ -32,88 +34,100 @@ const ZCreateUserAction = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const createUserAction = actionClient.schema(ZCreateUserAction).action(async ({ parsedInput }) => {
|
||||
if (IS_TURNSTILE_CONFIGURED) {
|
||||
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
if (IS_TURNSTILE_CONFIGURED) {
|
||||
if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("Server configuration error");
|
||||
}
|
||||
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken);
|
||||
if (!isHuman) {
|
||||
captureFailedSignup(parsedInput.email, parsedInput.name);
|
||||
throw new UnknownError("reCAPTCHA verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
const { inviteToken, emailVerificationDisabled } = parsedInput;
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const user = await createUser({
|
||||
email: parsedInput.email.toLowerCase(),
|
||||
name: parsedInput.name,
|
||||
password: hashedPassword,
|
||||
locale: parsedInput.userLocale,
|
||||
});
|
||||
const { inviteToken, emailVerificationDisabled } = parsedInput;
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
const user = await createUser({
|
||||
email: parsedInput.email.toLowerCase(),
|
||||
name: parsedInput.name,
|
||||
password: hashedPassword,
|
||||
locale: parsedInput.userLocale,
|
||||
});
|
||||
|
||||
// Handle invite flow
|
||||
if (inviteToken) {
|
||||
const inviteTokenData = verifyInviteToken(inviteToken);
|
||||
const invite = await getInvite(inviteTokenData.inviteId);
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invite ID");
|
||||
}
|
||||
// Handle invite flow
|
||||
if (inviteToken) {
|
||||
const inviteTokenData = verifyInviteToken(inviteToken);
|
||||
const invite = await getInvite(inviteTokenData.inviteId);
|
||||
if (!invite) {
|
||||
throw new Error("Invalid invite ID");
|
||||
}
|
||||
|
||||
await createMembership(invite.organizationId, user.id, {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
});
|
||||
|
||||
if (invite.teamIds) {
|
||||
await createTeamMembership(
|
||||
{
|
||||
organizationId: invite.organizationId,
|
||||
await createMembership(invite.organizationId, user.id, {
|
||||
accepted: true,
|
||||
role: invite.role,
|
||||
teamIds: invite.teamIds,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
});
|
||||
|
||||
if (invite.teamIds) {
|
||||
await createTeamMembership(
|
||||
{
|
||||
organizationId: invite.organizationId,
|
||||
role: invite.role,
|
||||
teamIds: invite.teamIds,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [invite.organizationId],
|
||||
},
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = invite.organizationId;
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
} else {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (isMultiOrgEnabled) {
|
||||
const organization = await createOrganization({ name: `${user.name}'s Organization` });
|
||||
await createMembership(organization.id, user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
alert: { ...user.notificationSettings?.alert },
|
||||
weeklySummary: { ...user.notificationSettings?.weeklySummary },
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organization.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Send verification email if enabled
|
||||
if (!emailVerificationDisabled) {
|
||||
await sendVerificationEmail(user);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = user.id;
|
||||
ctx.auditLoggingCtx.newObject = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [invite.organizationId],
|
||||
},
|
||||
});
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
} else {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (isMultiOrgEnabled) {
|
||||
const organization = await createOrganization({ name: `${user.name}'s Organization` });
|
||||
await createMembership(organization.id, user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
alert: { ...user.notificationSettings?.alert },
|
||||
weeklySummary: { ...user.notificationSettings?.weeklySummary },
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send verification email if enabled
|
||||
if (!emailVerificationDisabled) {
|
||||
await sendVerificationEmail(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user