mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-29 09:31:06 -05:00
Compare commits
17 Commits
saml-sso-e
...
fix/local-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfbed26f5 | ||
|
|
f7842789de | ||
|
|
59bdd5f065 | ||
|
|
8da1bc71a6 | ||
|
|
0e0259691c | ||
|
|
ac7831fa3d | ||
|
|
db32cb392f | ||
|
|
e5cb01bd88 | ||
|
|
cbef4c2a69 | ||
|
|
86948b70de | ||
|
|
dfe955ca7c | ||
|
|
eb4b2dde05 | ||
|
|
f2dae67813 | ||
|
|
3ffc9bd290 | ||
|
|
a9946737df | ||
|
|
ece3d508a2 | ||
|
|
0d1d227e6a |
101
.cursor/rules/database.mdc
Normal file
101
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
## Database Overview
|
||||
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
└── Environment (production/development)
|
||||
├── Survey
|
||||
├── Contact
|
||||
├── ActionClass
|
||||
└── Integration
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
Some models use soft deletion patterns:
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
Configured cascade relationships:
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
60
.github/workflows/deploy-formbricks-cloud.yml
vendored
60
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,16 +4,16 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
|
||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
@@ -22,16 +22,16 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
description: "The version of the Docker image to release"
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -100,3 +100,43 @@ jobs:
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
fi
|
||||
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||
|
||||
# Prepare JSON payload for selective cache purge
|
||||
json_payload=$(cat << EOF
|
||||
{
|
||||
"hosts": ["$PURGE_HOST"]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Make API call to Cloudflare
|
||||
response=$(curl -s -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CF_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$json_payload")
|
||||
|
||||
echo "Cloudflare API response: $response"
|
||||
|
||||
# Verify the operation was successful
|
||||
if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then
|
||||
echo "✅ Successfully purged cache for $PURGE_HOST"
|
||||
else
|
||||
echo "❌ Cloudflare cache purge failed"
|
||||
echo "Error details: $(echo "$response" | jq -r .errors)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
@@ -166,4 +166,4 @@ CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
(cd apps/web && exec node server.js)
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,36 @@ const mockSurveyWeb = {
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
INTERCOM_SECRET_KEY: "test-secret-key",
|
||||
IS_INTERCOM_CONFIGURED: true,
|
||||
INTERCOM_APP_ID: "test-app-id",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
POSTHOG_API_HOST: "test-posthog-api-host",
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
const mockSurveyLink = {
|
||||
...mockSurveyWeb,
|
||||
id: "survey2",
|
||||
@@ -174,20 +204,32 @@ describe("ShareEmbedSurvey", () => {
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
|
||||
const embedViewButton = screen.getByText("EmbedViewMockContent");
|
||||
await userEvent.click(embedViewButton);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
|
||||
// Should go back to start view, not close the modal
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
|
||||
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
|
||||
await userEvent.click(panelInfoViewButton);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
|
||||
// Should go back to start view, not close the modal
|
||||
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -62,6 +63,20 @@ export const ShareEmbedSurvey = ({
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, surveyDomain, "default");
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch survey URL:", error);
|
||||
// Fallback to a default URL if fetching fails
|
||||
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, surveyDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
setActiveId(tabs[3].id);
|
||||
@@ -86,7 +101,7 @@ export const ShareEmbedSurvey = ({
|
||||
};
|
||||
|
||||
const handleInitialPageButton = () => {
|
||||
setOpen(false);
|
||||
setShowView("start");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
|
||||
<SettingsId title={t("common.survey_id")} id={surveyId} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
204
apps/web/app/api/v1/management/storage/lib/utils.test.ts
Normal file
204
apps/web/app/api/v1/management/storage/lib/utils.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { checkForRequiredFields } from "./utils";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Session } from "next-auth";
|
||||
import { vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
|
||||
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
|
||||
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
badRequestResponse: vi.fn(() => mockBadRequestResponse),
|
||||
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
|
||||
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("checkForRequiredFields", () => {
|
||||
test("should return undefined when all required fields are present", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return bad request response when environmentId is missing", () => {
|
||||
const result = checkForRequiredFields("", "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when fileType is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when encodedFileName is missing", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", "");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when environmentId is undefined", () => {
|
||||
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when fileType is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
|
||||
test("should return bad request response when encodedFileName is undefined", () => {
|
||||
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
|
||||
expect(result).toBe(mockBadRequestResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAuth", () => {
|
||||
const environmentId = "env-123";
|
||||
const mockRequest = new NextRequest("http://localhost:3000/api/test");
|
||||
|
||||
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockNotAuthenticatedResponse);
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "read",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when no session and authentication has POST permission", async () => {
|
||||
const mockAuthentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-123",
|
||||
permission: "write",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
|
||||
const result = await checkAuth(null, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
|
||||
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
expect(result).toBe(mockUnauthorizedResponse);
|
||||
});
|
||||
|
||||
test("returns undefined when session exists and user has environment access", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
const result = await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not call authenticateRequest when session exists", async () => {
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
expires: "2024-12-31T23:59:59.999Z",
|
||||
};
|
||||
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
|
||||
|
||||
await checkAuth(mockSession, environmentId, mockRequest);
|
||||
|
||||
expect(authenticateRequest).not.toHaveBeenCalled();
|
||||
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
|
||||
});
|
||||
});
|
||||
38
apps/web/app/api/v1/management/storage/lib/utils.ts
Normal file
38
apps/web/app/api/v1/management/storage/lib/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Session } from "next-auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
|
||||
export const checkForRequiredFields = (environmentId: string, fileType: string, encodedFileName: string): Response | undefined => {
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
|
||||
if (!session) {
|
||||
//check whether its using API key
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else {
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -4,13 +4,13 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
|
||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
@@ -27,41 +27,17 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
const signedTimestamp = jsonInput.timestamp as string;
|
||||
const environmentId = jsonInput.environmentId as string;
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
|
||||
if (requiredFieldResponse) return requiredFieldResponse;
|
||||
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!encodedFileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!signedSignature) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedUuid) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!signedTimestamp) {
|
||||
if (!signedSignature || !signedUuid || !signedTimestamp) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
const authResponse = await checkAuth(session, environmentId, req);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
|
||||
|
||||
|
||||
// api endpoint for uploading public files
|
||||
// uploaded files will be public, anyone can access the file
|
||||
@@ -13,29 +14,26 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
|
||||
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
||||
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
|
||||
|
||||
export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
|
||||
export const POST = async (request: NextRequest): Promise<Response> => {
|
||||
let storageInput;
|
||||
|
||||
try {
|
||||
storageInput = await req.json();
|
||||
storageInput = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
|
||||
|
||||
if (!fileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
|
||||
if (requiredFieldResponse) return requiredFieldResponse;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!fileType) {
|
||||
return responses.badRequestResponse("fileType is required");
|
||||
}
|
||||
const authResponse = await checkAuth(session, environmentId, request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
@@ -53,18 +51,5 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
}
|
||||
}
|
||||
|
||||
// auth and upload private file
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -309,7 +309,7 @@ export const createJumpLogic = (
|
||||
// Helper function to create jump logic based on choice selection
|
||||
export const createChoiceJumpLogic = (
|
||||
sourceQuestionId: string,
|
||||
choiceId: string,
|
||||
choiceId: string | number,
|
||||
targetId: string
|
||||
): TSurveyLogic => ({
|
||||
id: createId(),
|
||||
|
||||
@@ -1210,6 +1210,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
|
||||
t("templates.feedback_box_question_1_choice_1"),
|
||||
t("templates.feedback_box_question_1_choice_2"),
|
||||
],
|
||||
choiceIds: [reusableOptionIds[0], reusableOptionIds[1]],
|
||||
headline: t("templates.feedback_box_question_1_headline"),
|
||||
required: true,
|
||||
subheader: t("templates.feedback_box_question_1_subheader"),
|
||||
@@ -2054,7 +2055,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
t("templates.professional_development_survey_question_1_choice_1"),
|
||||
t("templates.professional_development_survey_question_1_choice_1"),
|
||||
t("templates.professional_development_survey_question_1_choice_2"),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
@@ -2381,6 +2382,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
|
||||
t("templates.measure_task_accomplishment_question_1_option_2_label"),
|
||||
t("templates.measure_task_accomplishment_question_1_option_3_label"),
|
||||
],
|
||||
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2]],
|
||||
headline: t("templates.measure_task_accomplishment_question_1_headline"),
|
||||
required: true,
|
||||
t,
|
||||
@@ -2739,10 +2741,10 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
logic: [
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], "2", reusableQuestionIds[1]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], "3", reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], "4", reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], "5", localSurvey.endings[0].id),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], 2, reusableQuestionIds[1]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], 3, reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], 4, reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], 5, localSurvey.endings[0].id),
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
@@ -2795,7 +2797,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
logic: [
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], "5", reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[0], 5, reusableQuestionIds[2]),
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
@@ -2895,8 +2897,8 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
logic: [
|
||||
createChoiceJumpLogic(reusableQuestionIds[1], "3", reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[1], "4", reusableQuestionIds[3]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[1], 3, reusableQuestionIds[2]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[1], 4, reusableQuestionIds[3]),
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
@@ -2928,8 +2930,8 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[4],
|
||||
logic: [
|
||||
createChoiceJumpLogic(reusableQuestionIds[4], "3", reusableQuestionIds[5]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[4], "4", reusableQuestionIds[6]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[4], 3, reusableQuestionIds[5]),
|
||||
createChoiceJumpLogic(reusableQuestionIds[4], 4, reusableQuestionIds[6]),
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
@@ -3004,6 +3006,12 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
|
||||
t("templates.understand_low_engagement_question_1_choice_4"),
|
||||
t("templates.understand_low_engagement_question_1_choice_5"),
|
||||
],
|
||||
choiceIds: [
|
||||
reusableOptionIds[0],
|
||||
reusableOptionIds[1],
|
||||
reusableOptionIds[2],
|
||||
reusableOptionIds[3],
|
||||
],
|
||||
headline: t("templates.understand_low_engagement_question_1_headline"),
|
||||
required: true,
|
||||
containsOther: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,12 +53,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("hasOrganizationAccess should return true when user has membership", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
|
||||
@@ -74,12 +73,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("isManagerOrOwner should return true for manager role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
@@ -88,12 +86,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("isManagerOrOwner should return true for owner role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
@@ -102,12 +99,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("isManagerOrOwner should return false for member role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
|
||||
@@ -116,12 +112,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("isOwner should return true only for owner role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
|
||||
@@ -130,12 +125,11 @@ describe("Organization Access", () => {
|
||||
|
||||
test("isOwner should return false for non-owner roles", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
|
||||
@@ -153,12 +147,11 @@ describe("Organization Authority", () => {
|
||||
|
||||
test("hasOrganizationAuthority should return true for manager", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
|
||||
@@ -173,12 +166,11 @@ describe("Organization Authority", () => {
|
||||
|
||||
test("hasOrganizationAuthority should throw for member role", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
@@ -186,12 +178,11 @@ describe("Organization Authority", () => {
|
||||
|
||||
test("hasOrganizationOwnership should return true for owner", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
|
||||
@@ -206,12 +197,11 @@ describe("Organization Authority", () => {
|
||||
|
||||
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
|
||||
id: "membership123",
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
role: "manager",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
});
|
||||
|
||||
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -197,13 +197,15 @@ export const getFallbackValues = (text: string): fallbacks => {
|
||||
|
||||
// Transforms headlines in a text to their corresponding recall information.
|
||||
export const headlineToRecall = (
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
recallItems: TSurveyRecallItem[],
|
||||
fallbacks: fallbacks
|
||||
): string => {
|
||||
if (!text) return "";
|
||||
|
||||
recallItems.forEach((recallItem) => {
|
||||
const recallInfo = `#recall:${recallItem.id}/fallback:${fallbacks[recallItem.id]}#`;
|
||||
text = text.replace(`@${recallItem.label}`, recallInfo);
|
||||
text = text?.replace(`@${recallItem.label}`, recallInfo);
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Produktmanager",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "People Manager",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Softwareentwickler",
|
||||
"product_market_fit_superhuman_question_3_headline": "Was ist deine Rolle?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "Bitte wähle eine der folgenden Optionen aus:",
|
||||
"product_market_fit_superhuman_question_4_headline": "Wer würde am ehesten von $[projectName] profitieren?",
|
||||
"product_market_fit_superhuman_question_5_headline": "Welchen Mehrwert ziehst Du aus $[projectName]?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "Bewerte die Zufriedenheit der Mitarbeiter mit beruflichen Entwicklungsmöglichkeiten.",
|
||||
"professional_development_survey_name": "Berufliche Entwicklungsbewertung",
|
||||
"professional_development_survey_question_1_choice_1": "Ja",
|
||||
"professional_development_survey_question_1_choice_2": "Nein",
|
||||
"professional_development_survey_question_1_headline": "Sind Sie an beruflichen Entwicklungsmöglichkeiten interessiert?",
|
||||
"professional_development_survey_question_2_choice_1": "Networking-Veranstaltungen",
|
||||
"professional_development_survey_question_2_choice_2": "Konferenzen oder Seminare",
|
||||
|
||||
@@ -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",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Product Manager",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "Product Owner",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Software Engineer",
|
||||
"product_market_fit_superhuman_question_3_headline": "What is your role?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "Please select one of the following options:",
|
||||
"product_market_fit_superhuman_question_4_headline": "What type of people do you think would most benefit from $[projectName]?",
|
||||
"product_market_fit_superhuman_question_5_headline": "What is the main benefit you receive from $[projectName]?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "Assess employee satisfaction with professional growth and development opportunities.",
|
||||
"professional_development_survey_name": "Professional Development Survey",
|
||||
"professional_development_survey_question_1_choice_1": "Yes",
|
||||
"professional_development_survey_question_1_choice_2": "No",
|
||||
"professional_development_survey_question_1_headline": "Are you interested in professional development activities?",
|
||||
"professional_development_survey_question_2_choice_1": "Networking events",
|
||||
"professional_development_survey_question_2_choice_2": "Conferences or seminars",
|
||||
|
||||
@@ -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",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Chef de produit",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "Propriétaire de produit",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Ingénieur logiciel",
|
||||
"product_market_fit_superhuman_question_3_headline": "Quel est votre rôle ?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "Veuillez sélectionner l'une des options suivantes :",
|
||||
"product_market_fit_superhuman_question_4_headline": "Quel type de personnes pensez-vous bénéficierait le plus de $[projectName] ?",
|
||||
"product_market_fit_superhuman_question_5_headline": "Quel est le principal avantage que vous tirez de $[projectName] ?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "Évaluer la satisfaction des employés concernant les opportunités de croissance et de développement professionnel.",
|
||||
"professional_development_survey_name": "Sondage sur le développement professionnel",
|
||||
"professional_development_survey_question_1_choice_1": "Oui",
|
||||
"professional_development_survey_question_1_choice_2": "Non",
|
||||
"professional_development_survey_question_1_headline": "Êtes-vous intéressé par des activités de développement professionnel ?",
|
||||
"professional_development_survey_question_2_choice_1": "Événements de réseautage",
|
||||
"professional_development_survey_question_2_choice_2": "Conférences ou séminaires",
|
||||
|
||||
@@ -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",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Gerente de Produto",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "Dono do Produto",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software",
|
||||
"product_market_fit_superhuman_question_3_headline": "Qual é a sua função?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "Por favor, escolha uma das opções a seguir:",
|
||||
"product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas você acha que mais se beneficiariam do $[projectName]?",
|
||||
"product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que você recebe do $[projectName]?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "Avalie a satisfação dos funcionários com oportunidades de desenvolvimento profissional.",
|
||||
"professional_development_survey_name": "Avaliação de Desenvolvimento Profissional",
|
||||
"professional_development_survey_question_1_choice_1": "Sim",
|
||||
"professional_development_survey_question_1_choice_2": "Não",
|
||||
"professional_development_survey_question_1_headline": "Você está interessado em atividades de desenvolvimento profissional?",
|
||||
"professional_development_survey_question_2_choice_1": "Eventos de networking",
|
||||
"professional_development_survey_question_2_choice_2": "Conferencias ou seminários",
|
||||
|
||||
@@ -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",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Gestor de Produto",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "Proprietário do Produto",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Engenheiro de Software",
|
||||
"product_market_fit_superhuman_question_3_headline": "Qual é o seu papel?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "Por favor, selecione uma das seguintes opções:",
|
||||
"product_market_fit_superhuman_question_4_headline": "Que tipo de pessoas acha que mais beneficiariam de $[projectName]?",
|
||||
"product_market_fit_superhuman_question_5_headline": "Qual é o principal benefício que recebe de $[projectName]?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "Avaliar a satisfação dos funcionários com as oportunidades de crescimento e desenvolvimento profissional.",
|
||||
"professional_development_survey_name": "Inquérito de Desenvolvimento Profissional",
|
||||
"professional_development_survey_question_1_choice_1": "Sim",
|
||||
"professional_development_survey_question_1_choice_2": "Não",
|
||||
"professional_development_survey_question_1_headline": "Está interessado em atividades de desenvolvimento profissional?",
|
||||
"professional_development_survey_question_2_choice_1": "Eventos de networking",
|
||||
"professional_development_survey_question_2_choice_2": "Conferências ou seminários",
|
||||
|
||||
@@ -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": "姓氏",
|
||||
@@ -2630,6 +2629,7 @@
|
||||
"product_market_fit_superhuman_question_3_choice_3": "產品經理",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "產品負責人",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "軟體工程師",
|
||||
"product_market_fit_superhuman_question_3_headline": "您的角色是什麼?",
|
||||
"product_market_fit_superhuman_question_3_subheader": "請選取以下其中一個選項:",
|
||||
"product_market_fit_superhuman_question_4_headline": "您認為哪些類型的人最能從 {projectName} 中受益?",
|
||||
"product_market_fit_superhuman_question_5_headline": "您從 {projectName} 獲得的主要好處是什麼?",
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"professional_development_survey_description": "評估員工對專業成長和發展機會的滿意度。",
|
||||
"professional_development_survey_name": "專業發展問卷",
|
||||
"professional_development_survey_question_1_choice_1": "是",
|
||||
"professional_development_survey_question_1_choice_2": "否",
|
||||
"professional_development_survey_question_1_headline": "您對專業發展活動感興趣嗎?",
|
||||
"professional_development_survey_question_2_choice_1": "人脈交流活動",
|
||||
"professional_development_survey_question_2_choice_2": "研討會或研討會",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Copy, QrCode, RefreshCcw, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSurveyUrl } from "../../utils";
|
||||
import { LanguageDropdown } from "./components/LanguageDropdown";
|
||||
import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
|
||||
|
||||
@@ -31,46 +31,30 @@ export const ShareSurveyLink = ({
|
||||
const { t } = useTranslate();
|
||||
const [language, setLanguage] = useState("default");
|
||||
|
||||
const getUrl = useCallback(async () => {
|
||||
let url = `${surveyDomain}/s/${survey.id}`;
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseIdResponse = await generateSingleUseIdAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
});
|
||||
|
||||
if (singleUseIdResponse?.data) {
|
||||
queryParams.push(`suId=${singleUseIdResponse.data}`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
|
||||
useEffect(() => {
|
||||
const fetchSurveyUrl = async () => {
|
||||
try {
|
||||
const url = await getSurveyUrl(survey, surveyDomain, language);
|
||||
setSurveyUrl(url);
|
||||
} catch (error) {
|
||||
const errorMessage = getFormattedErrorMessage(error);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchSurveyUrl();
|
||||
}, [survey, language, surveyDomain, setSurveyUrl]);
|
||||
|
||||
const generateNewSingleUseLink = async () => {
|
||||
try {
|
||||
const newUrl = await getSurveyUrl(survey, surveyDomain, language);
|
||||
setSurveyUrl(newUrl);
|
||||
toast.success(t("environments.surveys.new_single_use_link_generated"));
|
||||
} catch (error) {
|
||||
const errorMessage = getFormattedErrorMessage(error);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
if (language !== "default") {
|
||||
queryParams.push(`lang=${language}`);
|
||||
}
|
||||
|
||||
if (queryParams.length) {
|
||||
url += `?${queryParams.join("&")}`;
|
||||
}
|
||||
|
||||
setSurveyUrl(url);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [survey, surveyDomain, language]);
|
||||
|
||||
const generateNewSingleUseLink = () => {
|
||||
getUrl();
|
||||
toast.success(t("environments.surveys.new_single_use_link_generated"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUrl();
|
||||
}, [survey, getUrl, language]);
|
||||
|
||||
const { downloadQRCode } = useSurveyQRCode(surveyUrl);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,14 @@ import { isValidElement } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { renderHyperlinkedContent } from "./utils";
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
generateSingleUseIdAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("renderHyperlinkedContent", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
|
||||
import { JSX } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Utility function to render hyperlinked content
|
||||
export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
|
||||
@@ -26,3 +29,36 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const getSurveyUrl = async (
|
||||
survey: TSurvey,
|
||||
surveyDomain: string,
|
||||
language: string
|
||||
): Promise<string> => {
|
||||
let url = `${surveyDomain}/s/${survey.id}`;
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseIdResponse = await generateSingleUseIdAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
});
|
||||
|
||||
if (singleUseIdResponse?.data) {
|
||||
queryParams.push(`suId=${singleUseIdResponse.data}`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(singleUseIdResponse);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (language !== "default") {
|
||||
queryParams.push(`lang=${language}`);
|
||||
}
|
||||
|
||||
if (queryParams.length) {
|
||||
url += `?${queryParams.join("&")}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user