diff --git a/apps/web/app/(app)/environments/[environmentId]/project/api-keys/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/project/api-keys/loading.tsx deleted file mode 100644 index 68619f57fb..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/project/api-keys/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading"; - -export default APIKeysLoading; diff --git a/apps/web/app/(app)/environments/[environmentId]/project/api-keys/page.tsx b/apps/web/app/(app)/environments/[environmentId]/project/api-keys/page.tsx deleted file mode 100644 index c631feeabc..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/project/api-keys/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { APIKeysPage } from "@/modules/projects/settings/api-keys/page"; - -export default APIKeysPage; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx new file mode 100644 index 0000000000..b3139471f6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx @@ -0,0 +1,6 @@ +import Loading from "@/modules/organization/settings/api-keys/loading"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; + +export default function LoadingPage() { + return ; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.tsx new file mode 100644 index 0000000000..c997500d7d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.tsx @@ -0,0 +1,3 @@ +import { APIKeysPage } from "@/modules/organization/settings/api-keys/page"; + +export default APIKeysPage; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index ae76f7ae27..18a0b6737e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({ hidden: isFormbricksCloud || isPricingDisabled, current: pathname?.includes("/enterprise"), }, + { + id: "api-keys", + label: t("common.api_keys"), + href: `/environments/${environmentId}/settings/api-keys`, + current: pathname?.includes("/api-keys"), + }, ]; return ; diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts new file mode 100644 index 0000000000..b8ed5ccc4b --- /dev/null +++ b/apps/web/app/api/v1/auth.test.ts @@ -0,0 +1,147 @@ +import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; +import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { describe, expect, it, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; +import { authenticateRequest } from "./auth"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/modules/api/v2/management/lib/utils", () => ({ + hashApiKey: vi.fn(), +})); + +describe("getApiKeyWithPermissions", () => { + it("should return API key data with permissions when valid key is provided", async () => { + const mockApiKeyData = { + id: "api-key-id", + organizationId: "org-id", + hashedKey: "hashed-key", + createdAt: new Date(), + createdBy: "user-id", + lastUsedAt: null, + label: "Test API Key", + apiKeyEnvironments: [ + { + environmentId: "env-1", + permission: "manage" as const, + environment: { id: "env-1" }, + }, + ], + }; + + vi.mocked(hashApiKey).mockReturnValue("hashed-key"); + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData); + vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData); + + const result = await getApiKeyWithPermissions("test-api-key"); + + expect(result).toEqual(mockApiKeyData); + expect(prisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: "api-key-id" }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it("should return null when API key is not found", async () => { + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null); + + const result = await getApiKeyWithPermissions("invalid-key"); + + expect(result).toBeNull(); + }); +}); + +describe("hasPermission", () => { + const permissions: TAPIKeyEnvironmentPermission[] = [ + { environmentId: "env-1", permission: "manage" }, + { environmentId: "env-2", permission: "write" }, + { environmentId: "env-3", permission: "read" }, + ]; + + it("should return true for manage permission with any method", () => { + expect(hasPermission(permissions, "env-1", "GET")).toBe(true); + expect(hasPermission(permissions, "env-1", "POST")).toBe(true); + expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true); + }); + + it("should handle write permission correctly", () => { + expect(hasPermission(permissions, "env-2", "GET")).toBe(true); + expect(hasPermission(permissions, "env-2", "POST")).toBe(true); + expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false); + }); + + it("should handle read permission correctly", () => { + expect(hasPermission(permissions, "env-3", "GET")).toBe(true); + expect(hasPermission(permissions, "env-3", "POST")).toBe(false); + expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false); + }); + + it("should return false for non-existent environment", () => { + expect(hasPermission(permissions, "env-4", "GET")).toBe(false); + }); +}); + +describe("authenticateRequest", () => { + it("should return authentication data for valid API key", async () => { + const request = new Request("http://localhost", { + headers: { "x-api-key": "valid-api-key" }, + }); + + const mockApiKeyData = { + id: "api-key-id", + organizationId: "org-id", + hashedKey: "hashed-key", + createdAt: new Date(), + createdBy: "user-id", + lastUsedAt: null, + label: "Test API Key", + apiKeyEnvironments: [ + { + environmentId: "env-1", + permission: "manage" as const, + environment: { id: "env-1" }, + }, + ], + }; + + vi.mocked(hashApiKey).mockReturnValue("hashed-key"); + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData); + vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData); + + const result = await authenticateRequest(request); + + expect(result).toEqual({ + type: "apiKey", + environmentPermissions: [{ environmentId: "env-1", permission: "manage" }], + hashedApiKey: "hashed-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + }); + }); + + it("should return null when no API key is provided", async () => { + const request = new Request("http://localhost"); + const result = await authenticateRequest(request); + expect(result).toBeNull(); + }); + + it("should return null when API key is invalid", async () => { + const request = new Request("http://localhost", { + headers: { "x-api-key": "invalid-api-key" }, + }); + + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null); + + const result = await authenticateRequest(request); + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index eeb67bca90..44cd415c69 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -1,25 +1,34 @@ -import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key"; import { responses } from "@/app/lib/api/response"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; +import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; export const authenticateRequest = async (request: Request): Promise => { const apiKey = request.headers.get("x-api-key"); - if (apiKey) { - const environmentId = await getEnvironmentIdFromApiKey(apiKey); - if (environmentId) { - const hashedApiKey = hashApiKey(apiKey); - const authentication: TAuthenticationApiKey = { - type: "apiKey", - environmentId, - hashedApiKey, - }; - return authentication; - } - return null; - } - return null; + if (!apiKey) return null; + + // Get API key with permissions + const apiKeyData = await getApiKeyWithPermissions(apiKey); + if (!apiKeyData) return null; + + // In the route handlers, we'll do more specific permission checks + const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId); + if (environmentIds.length === 0) return null; + + const hashedApiKey = hashApiKey(apiKey); + const authentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({ + environmentId: env.environmentId, + permission: env.permission, + })), + hashedApiKey, + apiKeyId: apiKeyData.id, + organizationId: apiKeyData.organizationId, + }; + + return authentication; }; export const handleErrorResponse = (error: any): Response => { diff --git a/apps/web/app/api/v1/lib/api-key.ts b/apps/web/app/api/v1/lib/api-key.ts deleted file mode 100644 index 62a69b315c..0000000000 --- a/apps/web/app/api/v1/lib/api-key.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { apiKeyCache } from "@/lib/cache/api-key"; -import { Prisma } from "@prisma/client"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { getHash } from "@formbricks/lib/crypto"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { ZString } from "@formbricks/types/common"; -import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; - -export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise => { - const hashedKey = getHash(apiKey); - return cache( - async () => { - validateInputs([apiKey, ZString]); - - if (!apiKey) { - throw new InvalidInputError("API key cannot be null or undefined."); - } - - try { - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey, - }, - select: { - environmentId: true, - }, - }); - - if (!apiKeyData) { - throw new ResourceNotFoundError("apiKey", apiKey); - } - - return apiKeyData.environmentId; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`management-api-getEnvironmentIdFromApiKey-${apiKey}`], - { - tags: [apiKeyCache.tag.byHashedKey(hashedKey)], - } - )(); -}); diff --git a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts index 6811814800..1a3e2c073b 100644 --- a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts +++ b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts @@ -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 { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; import { logger } from "@formbricks/logger"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; @@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth"; const fetchAndAuthorizeActionClass = async ( authentication: TAuthenticationApiKey, - actionClassId: string + actionClassId: string, + method: "GET" | "POST" | "PUT" | "DELETE" ): Promise => { + // Get the action class const actionClass = await getActionClass(actionClassId); if (!actionClass) { return null; } - if (actionClass.environmentId !== authentication.environmentId) { + + // Check if API key has permission to access this environment with appropriate permissions + if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) { throw new Error("Unauthorized"); } + return actionClass; }; @@ -28,7 +34,7 @@ export const GET = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET"); if (actionClass) { return responses.successResponse(actionClass); } @@ -46,7 +52,7 @@ export const PUT = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT"); if (!actionClass) { return responses.notFoundResponse("Action Class", params.actionClassId); } @@ -88,7 +94,7 @@ export const DELETE = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE"); if (!actionClass) { return responses.notFoundResponse("Action Class", params.actionClassId); } diff --git a/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts b/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts new file mode 100644 index 0000000000..a1a8f0410e --- /dev/null +++ b/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getActionClasses } from "./action-classes"; + +// Mock the prisma client +vi.mock("@formbricks/database", () => ({ + prisma: { + actionClass: { + findMany: vi.fn(), + }, + }, +})); + +describe("getActionClasses", () => { + const mockEnvironmentIds = ["env1", "env2"]; + const mockActionClasses = [ + { + id: "action1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Action 1", + description: "Test Description 1", + type: "click", + key: "test-key-1", + noCodeConfig: {}, + environmentId: "env1", + }, + { + id: "action2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Action 2", + description: "Test Description 2", + type: "pageview", + key: "test-key-2", + noCodeConfig: {}, + environmentId: "env2", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully fetch action classes for given environment IDs", async () => { + // Mock the prisma findMany response + vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); + + const result = await getActionClasses(mockEnvironmentIds); + + expect(result).toEqual(mockActionClasses); + expect(prisma.actionClass.findMany).toHaveBeenCalledWith({ + where: { + environmentId: { in: mockEnvironmentIds }, + }, + select: expect.any(Object), + orderBy: { + createdAt: "asc", + }, + }); + }); + + it("should throw DatabaseError when prisma query fails", async () => { + // Mock the prisma findMany to throw an error + vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error")); + + await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + }); + + it("should handle empty environment IDs array", async () => { + // Mock the prisma findMany response + vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]); + + const result = await getActionClasses([]); + + expect(result).toEqual([]); + expect(prisma.actionClass.findMany).toHaveBeenCalledWith({ + where: { + environmentId: { in: [] }, + }, + select: expect.any(Object), + orderBy: { + createdAt: "asc", + }, + }); + }); +}); diff --git a/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts b/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts new file mode 100644 index 0000000000..3cd0c2263b --- /dev/null +++ b/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts @@ -0,0 +1,51 @@ +"use server"; + +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { cache } from "@formbricks/lib/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; + +const selectActionClass = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + key: true, + noCodeConfig: true, + environmentId: true, +} satisfies Prisma.ActionClassSelect; + +export const getActionClasses = reactCache( + async (environmentIds: string[]): Promise => + cache( + async () => { + validateInputs([environmentIds, ZId.array()]); + + try { + return await prisma.actionClass.findMany({ + where: { + environmentId: { in: environmentIds }, + }, + select: selectActionClass, + orderBy: { + createdAt: "asc", + }, + }); + } catch (error) { + throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`); + } + }, + environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`), + { + tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)), + } + )() +); diff --git a/apps/web/app/api/v1/management/action-classes/route.ts b/apps/web/app/api/v1/management/action-classes/route.ts index 4bfc2922f8..378f64e528 100644 --- a/apps/web/app/api/v1/management/action-classes/route.ts +++ b/apps/web/app/api/v1/management/action-classes/route.ts @@ -1,16 +1,24 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { createActionClass } from "@formbricks/lib/actionClass/service"; import { logger } from "@formbricks/logger"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { DatabaseError } from "@formbricks/types/errors"; +import { getActionClasses } from "./lib/action-classes"; export const GET = async (request: Request) => { try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!); + + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + + const actionClasses = await getActionClasses(environmentIds); + return responses.successResponse(actionClasses); } catch (error) { if (error instanceof DatabaseError) { @@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise => { 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", @@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise => { ); } - const actionClass: TActionClass = await createActionClass( - authentication.environmentId!, - inputValidation.data - ); + const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data); return responses.successResponse(actionClass); } catch (error) { if (error instanceof DatabaseError) { diff --git a/apps/web/app/api/v1/management/me/route.ts b/apps/web/app/api/v1/management/me/route.ts index e43eff3bed..d4dd33017e 100644 --- a/apps/web/app/api/v1/management/me/route.ts +++ b/apps/web/app/api/v1/management/me/route.ts @@ -12,29 +12,56 @@ export const GET = async () => { hashedKey: hashApiKey(apiKey), }, select: { - environment: { + apiKeyEnvironments: { select: { - id: true, - createdAt: true, - updatedAt: true, - type: true, - project: { + environment: { select: { id: true, - name: true, + type: true, + createdAt: true, + updatedAt: true, + projectId: true, + widgetSetupCompleted: true, + project: { + select: { + id: true, + name: true, + }, + }, }, }, - appSetupCompleted: true, + permission: true, }, }, }, }); + if (!apiKeyData) { return new Response("Not authenticated", { status: 401, }); } - return Response.json(apiKeyData.environment); + + if ( + apiKeyData.apiKeyEnvironments.length === 1 && + apiKeyData.apiKeyEnvironments[0].permission === "manage" + ) { + return Response.json({ + id: apiKeyData.apiKeyEnvironments[0].environment.id, + type: apiKeyData.apiKeyEnvironments[0].environment.type, + createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt, + updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt, + widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted, + project: { + id: apiKeyData.apiKeyEnvironments[0].environment.projectId, + name: apiKeyData.apiKeyEnvironments[0].environment.project.name, + }, + }); + } else { + return new Response("You can't use this method with this API key", { + status: 400, + }); + } } else { const sessionUser = await getSessionUser(); if (!sessionUser) { diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index 28f7c7d304..43fac5e93e 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -1,32 +1,33 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service"; import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; -import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses"; +import { ZResponseUpdateInput } from "@formbricks/types/responses"; -const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise => { +async function fetchAndAuthorizeResponse( + responseId: string, + authentication: any, + requiredPermission: "GET" | "PUT" | "DELETE" +) { const response = await getResponse(responseId); - if (!response || !(await canUserAccessResponse(authentication, response))) { - throw new Error("Unauthorized"); + if (!response) { + return { error: responses.notFoundResponse("Response", responseId) }; } - return response; -}; -const canUserAccessResponse = async (authentication: any, response: TResponse): Promise => { const survey = await getSurvey(response.surveyId); - if (!survey) return false; - - if (authentication.type === "session") { - return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId); - } else if (authentication.type === "apiKey") { - return survey.environmentId === authentication.environmentId; - } else { - throw Error("Unknown authentication type"); + if (!survey) { + return { error: responses.notFoundResponse("Survey", response.surveyId, true) }; } -}; + + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) { + return { error: responses.unauthorizedResponse() }; + } + + return { response }; +} export const GET = async ( request: Request, @@ -36,11 +37,11 @@ export const GET = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const response = await fetchAndValidateResponse(authentication, params.responseId); - if (response) { - return responses.successResponse(response); - } - return responses.notFoundResponse("Response", params.responseId); + + const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET"); + if (result.error) return result.error; + + return responses.successResponse(result.response); } catch (error) { return handleErrorResponse(error); } @@ -54,10 +55,10 @@ export const DELETE = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const response = await fetchAndValidateResponse(authentication, params.responseId); - if (!response) { - return responses.notFoundResponse("Response", params.responseId); - } + + 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) { @@ -73,7 +74,10 @@ export const PUT = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - await fetchAndValidateResponse(authentication, params.responseId); + + const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT"); + if (result.error) return result.error; + let responseUpdate; try { responseUpdate = await request.json(); diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts index d961371381..bd5c80d567 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.ts @@ -1,6 +1,8 @@ import "server-only"; import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getMonthlyOrganizationResponseCount, @@ -8,11 +10,13 @@ import { } from "@formbricks/lib/organization/service"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; import { responseCache } from "@formbricks/lib/response/cache"; +import { getResponseContact } from "@formbricks/lib/response/service"; import { calculateTtcTotal } from "@formbricks/lib/response/utils"; import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; import { captureTelemetry } from "@formbricks/lib/telemetry"; import { validateInputs } from "@formbricks/lib/utils/validate"; import { logger } from "@formbricks/logger"; +import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; @@ -25,6 +29,7 @@ export const responseSelection = { updatedAt: true, surveyId: true, finished: true, + endingId: true, data: true, meta: true, ttc: true, @@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise => + cache( + async () => { + validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + try { + const responses = await prisma.response.findMany({ + where: { + survey: { + environmentId: { in: environmentIds }, + }, + }, + select: responseSelection, + orderBy: [ + { + createdAt: "desc", + }, + ], + take: limit ? limit : undefined, + skip: offset ? offset : undefined, + }); + + const transformedResponses: TResponse[] = await Promise.all( + responses.map((responsePrisma) => { + return { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + }) + ); + + return transformedResponses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + environmentIds.map( + (environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}` + ), + { + tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)), + } + )() +); diff --git a/apps/web/app/api/v1/management/responses/route.ts b/apps/web/app/api/v1/management/responses/route.ts index daefc8975a..fe3fb059ad 100644 --- a/apps/web/app/api/v1/management/responses/route.ts +++ b/apps/web/app/api/v1/management/responses/route.ts @@ -1,13 +1,14 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; -import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service"; +import { getResponses } from "@formbricks/lib/response/service"; import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { TResponse, ZResponseInput } from "@formbricks/types/responses"; -import { createResponse } from "./lib/response"; +import { createResponse, getResponsesByEnvironmentIds } from "./lib/response"; export const GET = async (request: NextRequest) => { const searchParams = request.nextUrl.searchParams; @@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => { try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - let environmentResponses: TResponse[] = []; + let allResponses: TResponse[] = []; if (surveyId) { - environmentResponses = await getResponses(surveyId, limit, offset); + const survey = await getSurvey(surveyId); + if (!survey) { + return responses.notFoundResponse("Survey", surveyId, true); + } + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { + return responses.unauthorizedResponse(); + } + const surveyResponses = await getResponses(surveyId, limit, offset); + allResponses.push(...surveyResponses); } else { - environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset); + allResponses.push(...environmentResponses); } - return responses.successResponse(environmentResponses); + return responses.successResponse(allResponses); } catch (error) { if (error instanceof DatabaseError) { return responses.badRequestResponse(error.message); @@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise => { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const environmentId = authentication.environmentId; - let jsonInput; try { @@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise => { return responses.badRequestResponse("Malformed JSON input, please check your request body"); } - // add environmentId to response - jsonInput.environmentId = environmentId; - const inputValidation = ZResponseInput.safeParse(jsonInput); if (!inputValidation.success) { @@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise => { const responseInput = inputValidation.data; + const environmentId = responseInput.environmentId; + + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return responses.unauthorizedResponse(); + } + // get and check survey const survey = await getSurvey(responseInput.surveyId); if (!survey) { diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts index 34749304bc..1e5f46a4d6 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; -import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; +import { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; -const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise => { +const fetchAndAuthorizeSurvey = async ( + surveyId: string, + authentication: TAuthenticationApiKey, + requiredPermission: "GET" | "PUT" | "DELETE" +) => { const survey = await getSurvey(surveyId); if (!survey) { - return null; + return { error: responses.notFoundResponse("Survey", surveyId) }; } - if (survey.environmentId !== authentication.environmentId) { - throw new Error("Unauthorized"); + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) { + return { error: responses.unauthorizedResponse() }; } - return survey; + + return { survey }; }; export const GET = async ( @@ -28,11 +35,9 @@ export const GET = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); - if (survey) { - return responses.successResponse(survey); - } - return responses.notFoundResponse("Survey", params.surveyId); + const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET"); + if (result.error) return result.error; + return responses.successResponse(result.survey); } catch (error) { return handleErrorResponse(error); } @@ -46,10 +51,8 @@ export const DELETE = async ( try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", params.surveyId); - } + 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) { @@ -65,13 +68,10 @@ export const PUT = async ( 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 survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", params.surveyId); - } - - const organization = await getOrganizationByEnvironmentId(authentication.environmentId); + const organization = await getOrganizationByEnvironmentId(result.survey.environmentId); if (!organization) { return responses.notFoundResponse("Organization", null); } @@ -85,7 +85,7 @@ export const PUT = async ( } const inputValidation = ZSurveyUpdateInput.safeParse({ - ...survey, + ...result.survey, ...surveyUpdate, }); diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts index 7b883a3720..93439f92f3 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts @@ -1,5 +1,6 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; import { getSurvey } from "@formbricks/lib/survey/service"; @@ -17,8 +18,8 @@ export const GET = async ( if (!survey) { return responses.notFoundResponse("Survey", params.surveyId); } - if (survey.environmentId !== authentication.environmentId) { - throw new Error("Unauthorized"); + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { + return responses.unauthorizedResponse(); } if (!survey.singleUse || !survey.singleUse.enabled) { diff --git a/apps/web/app/api/v1/management/surveys/lib/surveys.ts b/apps/web/app/api/v1/management/surveys/lib/surveys.ts new file mode 100644 index 0000000000..9529a51ed5 --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/lib/surveys.ts @@ -0,0 +1,48 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { selectSurvey } from "@formbricks/lib/survey/service"; +import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { logger } from "@formbricks/logger"; +import { ZOptionalNumber } from "@formbricks/types/common"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +export const getSurveys = reactCache( + async (environmentIds: string[], limit?: number, offset?: number): Promise => + cache( + async () => { + validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + + try { + const surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId: { in: environmentIds }, + }, + select: selectSurvey, + orderBy: { + updatedAt: "desc", + }, + take: limit, + skip: offset, + }); + return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting surveys"); + throw new DatabaseError(error.message); + } + throw error; + } + }, + environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`), + { + tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)), + } + )() +); diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index 06ee2cee4d..c9db2c4e38 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; +import { createSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; -import { ZSurveyCreateInput } from "@formbricks/types/surveys/types"; +import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; +import { getSurveys } from "./lib/surveys"; export const GET = async (request: Request) => { try { @@ -18,7 +20,11 @@ export const GET = async (request: Request) => { const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined; const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined; - const surveys = await getSurveys(authentication.environmentId!, limit, offset); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const surveys = await getSurveys(environmentIds, limit, offset); + return responses.successResponse(surveys); } catch (error) { if (error instanceof DatabaseError) { @@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise => { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const organization = await getOrganizationByEnvironmentId(authentication.environmentId); - if (!organization) { - return responses.notFoundResponse("Organization", null); - } - let surveyInput; try { surveyInput = await request.json(); @@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise => { logger.error({ error, url: request.url }, "Error parsing JSON"); return responses.badRequestResponse("Malformed JSON input, please check your request body"); } - - const inputValidation = ZSurveyCreateInput.safeParse(surveyInput); + const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput); if (!inputValidation.success) { return responses.badRequestResponse( @@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise => { ); } - const environmentId = authentication.environmentId; - const surveyData = { ...inputValidation.data, environmentId: undefined }; + const environmentId = inputValidation.data.environmentId; + + 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 }; if (surveyData.followUps?.length) { const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); @@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise => { } } - const survey = await createSurvey(environmentId, surveyData); + const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined }); return responses.successResponse(survey); } catch (error) { if (error instanceof DatabaseError) { diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts index 9dd12a4c47..de6ac15b0b 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -1,18 +1,19 @@ -import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key"; +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 { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { headers } from "next/headers"; import { logger } from "@formbricks/logger"; -export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => { +export const GET = 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 environmentId = await getEnvironmentIdFromApiKey(apiKey); - if (!environmentId) { + const authentication = await authenticateRequest(request); + if (!authentication) { return responses.notAuthenticatedResponse(); } @@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri if (!webhook) { return responses.notFoundResponse("Webhook", params.webhookId); } - if (webhook.environmentId !== environmentId) { + if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) { return responses.unauthorizedResponse(); } return responses.successResponse(webhook); @@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo if (!apiKey) { return responses.notAuthenticatedResponse(); } - const environmentId = await getEnvironmentIdFromApiKey(apiKey); - if (!environmentId) { + const authentication = await authenticateRequest(request); + if (!authentication) { return responses.notAuthenticatedResponse(); } @@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo if (!webhook) { return responses.notFoundResponse("Webhook", params.webhookId); } - if (webhook.environmentId !== environmentId) { + if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) { return responses.unauthorizedResponse(); } diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.ts b/apps/web/app/api/v1/webhooks/lib/webhook.ts index 66f546aa22..a1dedd70fa 100644 --- a/apps/web/app/api/v1/webhooks/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/lib/webhook.ts @@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; -export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise => { - validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]); +export const createWebhook = async (webhookInput: TWebhookInput): Promise => { + validateInputs([webhookInput, ZWebhookInput]); try { const createdWebhook = await prisma.webhook.create({ data: { - ...webhookInput, + url: webhookInput.url, + name: webhookInput.name, + source: webhookInput.source, surveyIds: webhookInput.surveyIds || [], + triggers: webhookInput.triggers || [], environment: { connect: { - id: environmentId, + id: webhookInput.environmentId, }, }, }, @@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo } if (!(error instanceof InvalidInputError)) { - throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`); + throw new DatabaseError( + `Database error when creating webhook for environment ${webhookInput.environmentId}` + ); } throw error; } }; -export const getWebhooks = (environmentId: string, page?: number): Promise => +export const getWebhooks = (environmentIds: string[], page?: number): Promise => cache( async () => { - validateInputs([environmentId, ZId], [page, ZOptionalNumber]); + validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]); try { const webhooks = await prisma.webhook.findMany({ where: { - environmentId: environmentId, + environmentId: { in: environmentIds }, }, take: page ? ITEMS_PER_PAGE : undefined, skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, @@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise `getWebhooks-${environmentId}-${page}`), { - tags: [webhookCache.tag.byEnvironmentId(environmentId)], + tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)), } )(); diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 8a4b58abc9..415fb8501e 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -1,42 +1,33 @@ -import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key"; +import { authenticateRequest } from "@/app/api/v1/auth"; 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 { headers } from "next/headers"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; -export const GET = async () => { - const headersList = await headers(); - const apiKey = headersList.get("x-api-key"); - if (!apiKey) { +export const GET = async (request: Request) => { + const authentication = await authenticateRequest(request); + if (!authentication) { return responses.notAuthenticatedResponse(); } - const environmentId = await getEnvironmentIdFromApiKey(apiKey); - if (!environmentId) { - return responses.notAuthenticatedResponse(); - } - - // get webhooks from database try { - const webhooks = await getWebhooks(environmentId); - return Response.json({ data: webhooks }); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const webhooks = await getWebhooks(environmentIds); + return responses.successResponse(webhooks); } catch (error) { if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + return responses.internalServerErrorResponse(error.message); } - return responses.internalServerErrorResponse(error.message); + throw error; } }; export const POST = async (request: Request) => { - const headersList = await headers(); - const apiKey = headersList.get("x-api-key"); - if (!apiKey) { - return responses.notAuthenticatedResponse(); - } - const environmentId = await getEnvironmentIdFromApiKey(apiKey); - if (!environmentId) { + const authentication = await authenticateRequest(request); + if (!authentication) { return responses.notAuthenticatedResponse(); } const webhookInput = await request.json(); @@ -50,9 +41,19 @@ export const POST = async (request: Request) => { ); } + 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(environmentId, inputValidation.data); + const webhook = await createWebhook(inputValidation.data); return responses.successResponse(webhook); } catch (error) { if (error instanceof InvalidInputError) { diff --git a/apps/web/app/api/v1/webhooks/types/webhooks.ts b/apps/web/app/api/v1/webhooks/types/webhooks.ts index a0c18a66d0..37dbaf7b21 100644 --- a/apps/web/app/api/v1/webhooks/types/webhooks.ts +++ b/apps/web/app/api/v1/webhooks/types/webhooks.ts @@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({ surveyIds: true, triggers: true, url: true, + environmentId: true, }); export type TWebhookInput = z.infer; diff --git a/apps/web/lib/cache/api-key.ts b/apps/web/lib/cache/api-key.ts index 3538c883c7..f0dc9158bb 100644 --- a/apps/web/lib/cache/api-key.ts +++ b/apps/web/lib/cache/api-key.ts @@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache"; interface RevalidateProps { id?: string; - environmentId?: string; hashedKey?: string; + organizationId?: string; } export const apiKeyCache = { @@ -11,24 +11,24 @@ export const apiKeyCache = { byId(id: string) { return `apiKeys-${id}`; }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-apiKeys`; - }, byHashedKey(hashedKey: string) { return `apiKeys-${hashedKey}-apiKey`; }, + byOrganizationId(organizationId: string) { + return `organizations-${organizationId}-apiKeys`; + }, }, - revalidate({ id, environmentId, hashedKey }: RevalidateProps): void { + revalidate({ id, hashedKey, organizationId }: RevalidateProps): void { if (id) { revalidateTag(this.tag.byId(id)); } - if (environmentId) { - revalidateTag(this.tag.byEnvironmentId(environmentId)); - } - if (hashedKey) { revalidateTag(this.tag.byHashedKey(hashedKey)); } + + if (organizationId) { + revalidateTag(this.tag.byOrganizationId(organizationId)); + } }, }; diff --git a/apps/web/lib/utils/helper.ts b/apps/web/lib/utils/helper.ts index 28682dd770..6b54561681 100644 --- a/apps/web/lib/utils/helper.ts +++ b/apps/web/lib/utils/helper.ts @@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => { throw new ResourceNotFoundError("apiKey", apiKeyId); } - return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId); + return apiKeyFromServer.organizationId; }; export const getOrganizationIdFromInviteId = async (inviteId: string) => { @@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => { return await getProjectIdFromEnvironmentId(segment.environmentId); }; -export const getProjectIdFromApiKeyId = async (apiKeyId: string) => { - const apiKey = await getApiKey(apiKeyId); - if (!apiKey) { - throw new ResourceNotFoundError("apiKey", apiKeyId); - } - - return await getProjectIdFromEnvironmentId(apiKey.environmentId); -}; - export const getProjectIdFromActionClassId = async (actionClassId: string) => { const actionClass = await getActionClass(actionClassId); if (!actionClass) { diff --git a/apps/web/lib/utils/services.ts b/apps/web/lib/utils/services.ts index 8f80fdb7c1..05f2d3ab3c 100644 --- a/apps/web/lib/utils/services.ts +++ b/apps/web/lib/utils/services.ts @@ -51,7 +51,7 @@ export const getActionClass = reactCache( ); export const getApiKey = reactCache( - async (apiKeyId: string): Promise<{ environmentId: string } | null> => + async (apiKeyId: string): Promise<{ organizationId: string } | null> => cache( async () => { validateInputs([apiKeyId, ZString]); @@ -66,7 +66,7 @@ export const getApiKey = reactCache( id: apiKeyId, }, select: { - environmentId: true, + organizationId: true, }, }); diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/management/auth/authenticate-request.ts index f0ec9e3165..2ec737799e 100644 --- a/apps/web/modules/api/v2/management/auth/authenticate-request.ts +++ b/apps/web/modules/api/v2/management/auth/authenticate-request.ts @@ -1,6 +1,6 @@ -import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { Result, err, ok } from "@formbricks/types/error-handlers"; @@ -8,27 +8,22 @@ export const authenticateRequest = async ( request: Request ): Promise> => { const apiKey = request.headers.get("x-api-key"); + if (!apiKey) return err({ type: "unauthorized" }); - if (apiKey) { - const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey); - if (!environmentIdResult.ok) { - return err(environmentIdResult.error); - } - const environmentId = environmentIdResult.data; - const hashedApiKey = hashApiKey(apiKey); - if (environmentId) { - const authentication: TAuthenticationApiKey = { - type: "apiKey", - environmentId, - hashedApiKey, - }; - return ok(authentication); - } - return err({ - type: "forbidden", - }); - } - return err({ - type: "unauthorized", - }); + const apiKeyData = await getApiKeyWithPermissions(apiKey); + if (!apiKeyData) return err({ type: "unauthorized" }); + + const hashedApiKey = hashApiKey(apiKey); + + const authentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({ + environmentId: env.environmentId, + permission: env.permission, + })), + hashedApiKey, + apiKeyId: apiKeyData.id, + organizationId: apiKeyData.organizationId, + }; + return ok(authentication); }; diff --git a/apps/web/modules/api/v2/management/auth/check-authorization.ts b/apps/web/modules/api/v2/management/auth/check-authorization.ts deleted file mode 100644 index dcfa4bb2fc..0000000000 --- a/apps/web/modules/api/v2/management/auth/check-authorization.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { Result, err, okVoid } from "@formbricks/types/error-handlers"; - -export const checkAuthorization = ({ - authentication, - environmentId, -}: { - authentication: TAuthenticationApiKey; - environmentId: string; -}): Result => { - if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) { - return err({ - type: "unauthorized", - }); - } - return okVoid(); -}; diff --git a/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts b/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts index aef4ea4982..02f4622e6e 100644 --- a/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts +++ b/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts @@ -1,11 +1,15 @@ -import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; import { describe, expect, it, vi } from "vitest"; -import { err, ok } from "@formbricks/types/error-handlers"; +import { prisma } from "@formbricks/database"; import { authenticateRequest } from "../authenticate-request"; -vi.mock("@/modules/api/v2/management/lib/api-key", () => ({ - getEnvironmentIdFromApiKey: vi.fn(), +vi.mock("@formbricks/database", () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, })); vi.mock("@/modules/api/v2/management/lib/utils", () => ({ @@ -17,8 +21,32 @@ describe("authenticateRequest", () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); - vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id")); + + const mockApiKeyData = { + id: "api-key-id", + organizationId: "org-id", + createdAt: new Date(), + createdBy: "user-id", + lastUsedAt: null, + label: "Test API Key", + hashedKey: "hashed-api-key", + apiKeyEnvironments: [ + { + environmentId: "env-id-1", + permission: "manage", + environment: { id: "env-id-1" }, + }, + { + environmentId: "env-id-2", + permission: "read", + environment: { id: "env-id-2" }, + }, + ], + }; + vi.mocked(hashApiKey).mockReturnValue("hashed-api-key"); + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData); + vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData); const result = await authenticateRequest(request); @@ -26,37 +54,28 @@ describe("authenticateRequest", () => { if (result.ok) { expect(result.data).toEqual({ type: "apiKey", - environmentId: "env-id", + environmentPermissions: [ + { environmentId: "env-id-1", permission: "manage" }, + { environmentId: "env-id-2", permission: "read" }, + ], hashedApiKey: "hashed-api-key", + apiKeyId: "api-key-id", + organizationId: "org-id", }); } }); - it("should return forbidden error if environmentId is not found", async () => { + it("should return unauthorized error if apiKey is not found", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "invalid-api-key" }, }); - vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" })); + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null); const result = await authenticateRequest(request); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toEqual({ type: "forbidden" }); - } - }); - - it("should return forbidden error if environmentId is empty", async () => { - const request = new Request("http://localhost", { - headers: { "x-api-key": "invalid-api-key" }, - }); - vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("")); - - const result = await authenticateRequest(request); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toEqual({ type: "forbidden" }); + expect(result.error).toEqual({ type: "unauthorized" }); } }); diff --git a/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts b/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts deleted file mode 100644 index 2afe725b10..0000000000 --- a/apps/web/modules/api/v2/management/auth/tests/check-authorization.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { checkAuthorization } from "../check-authorization"; - -describe("checkAuthorization", () => { - it("should return ok if authentication is valid", () => { - const authentication: TAuthenticationApiKey = { - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }; - const result = checkAuthorization({ authentication, environmentId: "env-id" }); - - expect(result.ok).toBe(true); - }); - - it("should return unauthorized error if environmentId does not match", () => { - const authentication: TAuthenticationApiKey = { - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }; - const result = checkAuthorization({ authentication, environmentId: "different-env-id" }); - - expect(result.ok).toBe(false); - - if (!result.ok) { - expect(result.error).toEqual({ type: "unauthorized" }); - } - }); -}); diff --git a/apps/web/modules/api/v2/management/lib/api-key.ts b/apps/web/modules/api/v2/management/lib/api-key.ts deleted file mode 100644 index eb894594b2..0000000000 --- a/apps/web/modules/api/v2/management/lib/api-key.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { apiKeyCache } from "@/lib/cache/api-key"; -import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; - -export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => { - const hashedKey = hashApiKey(apiKey); - return cache( - async (): Promise> => { - if (!apiKey) { - return err({ - type: "bad_request", - details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }], - }); - } - - try { - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - hashedKey, - }, - select: { - environmentId: true, - }, - }); - - if (!apiKeyData) { - return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] }); - } - - return ok(apiKeyData.environmentId); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] }); - } - }, - [`management-api-getEnvironmentIdFromApiKey-${hashedKey}`], - { - tags: [apiKeyCache.tag.byHashedKey(hashedKey)], - } - )(); -}); diff --git a/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts b/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts deleted file mode 100644 index 2b316807de..0000000000 --- a/apps/web/modules/api/v2/management/lib/tests/api-key.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { apiKey, environmentId } from "./__mocks__/api-key.mock"; -import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key"; -import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { prisma } from "@formbricks/database"; - -vi.mock("@formbricks/database", () => ({ - prisma: { - apiKey: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/modules/api/v2/management/lib/utils", () => ({ - hashApiKey: vi.fn((input: string) => `hashed-${input}`), -})); - -describe("getEnvironmentIdFromApiKey", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("returns a bad_request error if apiKey is empty", async () => { - const result = await getEnvironmentIdFromApiKey(""); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("bad_request"); - expect(result.error.details).toEqual([ - { field: "apiKey", issue: "API key cannot be null or undefined." }, - ]); - } - }); - - test("returns a not_found error when no apiKey record is found in the database", async () => { - vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`); - vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null); - - const result = await getEnvironmentIdFromApiKey(apiKey); - expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ - where: { hashedKey: `hashed-${apiKey}` }, - select: { environmentId: true }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("not_found"); - expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]); - } - }); - - test("returns ok with environmentId when a valid apiKey record is found", async () => { - vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`); - vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId }); - - const result = await getEnvironmentIdFromApiKey(apiKey); - expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ - where: { hashedKey: `hashed-${apiKey}` }, - select: { environmentId: true }, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data).toBe(environmentId); - } - }); - - test("returns internal_server_error when an exception occurs during the database lookup", async () => { - vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`); - vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure")); - - const result = await getEnvironmentIdFromApiKey(apiKey); - expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ - where: { hashedKey: `hashed-${apiKey}` }, - select: { environmentId: true }, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("internal_server_error"); - expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]); - } - }); -}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 90443a5202..7d3f182c93 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -1,13 +1,13 @@ import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client"; -import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; import { deleteResponse, getResponse, updateResponse, } from "@/modules/api/v2/management/responses/[responseId]/lib/response"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { z } from "zod"; import { responseIdSchema, responseUpdateSchema } from "./types/responses"; @@ -33,13 +33,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI return handleApiError(request, environmentIdResult.error); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: environmentIdResult.data, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "GET")) { + return handleApiError(request, { + type: "unauthorized", + }); } const response = await getResponse(params.responseId); @@ -73,13 +70,10 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon return handleApiError(request, environmentIdResult.error); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: environmentIdResult.data, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) { + return handleApiError(request, { + type: "unauthorized", + }); } const response = await deleteResponse(params.responseId); @@ -115,13 +109,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str return handleApiError(request, environmentIdResult.error); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: environmentIdResult.data, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) { + return handleApiError(request, { + type: "unauthorized", + }); } const response = await updateResponse(params.responseId, body); diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index 6e0ce2516d..5ba79f8408 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -130,16 +130,16 @@ export const createResponse = async ( }; export const getResponses = async ( - environmentId: string, + environmentIds: string[], params: TGetResponsesFilter ): Promise, ApiErrorResponseV2>> => { try { const [responses, count] = await prisma.$transaction([ prisma.response.findMany({ - ...getResponsesQuery(environmentId, params), + ...getResponsesQuery(environmentIds, params), }), prisma.response.count({ - where: getResponsesQuery(environmentId, params).where, + where: getResponsesQuery(environmentIds, params).where, }), ]); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts index 6ee8be7731..14c0ab4fce 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts @@ -11,12 +11,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ describe("getResponsesQuery", () => { it("adds surveyId to where clause if provided", () => { - const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter); + const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter); expect(result?.where?.surveyId).toBe("survey123"); }); it("adds contactId to where clause if provided", () => { - const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter); + const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter); expect(result?.where?.contactId).toBe("contact123"); }); @@ -24,12 +24,12 @@ describe("getResponsesQuery", () => { vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any); vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any }); - const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter); + const result = getResponsesQuery(["env-id"], { surveyId: "test" } as TGetResponsesFilter); expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" }); expect(buildCommonFilterQuery).toHaveBeenCalledWith( expect.objectContaining({ where: { - survey: { environmentId: "env-id" }, + survey: { environmentId: { in: ["env-id"] } }, surveyId: "test", }, }), diff --git a/apps/web/modules/api/v2/management/responses/lib/utils.ts b/apps/web/modules/api/v2/management/responses/lib/utils.ts index 5fa258311c..b1d2df134d 100644 --- a/apps/web/modules/api/v2/management/responses/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/lib/utils.ts @@ -2,11 +2,11 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses"; import { Prisma } from "@prisma/client"; -export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => { +export const getResponsesQuery = (environmentIds: string[], params?: TGetResponsesFilter) => { let query: Prisma.ResponseFindManyArgs = { where: { survey: { - environmentId, + environmentId: { in: environmentIds }, }, }, }; diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index afa4faff30..d23611f104 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -1,9 +1,10 @@ import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client"; -import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { Response } from "@prisma/client"; import { NextRequest } from "next/server"; import { createResponse, getResponses } from "./lib/response"; @@ -23,15 +24,20 @@ export const GET = async (request: NextRequest) => }); } - const environmentId = authentication.environmentId; + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); - const res = await getResponses(environmentId, query); + const environmentResponses: Response[] = []; + const res = await getResponses(environmentIds, query); - if (res.ok) { - return responses.successResponse(res.data); + if (!res.ok) { + return handleApiError(request, res.error); } - return handleApiError(request, res.error); + environmentResponses.push(...res.data.data); + + return responses.successResponse({ data: environmentResponses }); }, }); @@ -59,13 +65,10 @@ export const POST = async (request: Request) => const environmentId = environmentIdResult.data; - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return handleApiError(request, { + type: "unauthorized", + }); } // if there is a createdAt but no updatedAt, set updatedAt to createdAt diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts index 9d958a4ff5..0012d91e47 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts @@ -1,12 +1,12 @@ import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client"; -import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts"; import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response"; import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; @@ -43,13 +43,10 @@ export const GET = async ( const environmentId = environmentIdResult.data; - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + }); } const surveyResult = await getSurvey(params.surveyId); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts index 2c1fa0cb53..b5da782044 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts @@ -1,7 +1,6 @@ import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client"; -import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization"; import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper"; import { deleteWebhook, @@ -12,6 +11,7 @@ import { webhookIdSchema, webhookUpdateSchema, } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -38,13 +38,11 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho return handleApiError(request, webhook.error); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: webhook.ok ? webhook.data.environmentId : "", - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "webhook", issue: "unauthorized" }], + }); } return responses.successResponse(webhook); @@ -83,14 +81,11 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho return handleApiError(request, webhook.error); } - // check webhook environment against the api key environment - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: webhook.ok ? webhook.data.environmentId : "", - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "webhook", issue: "unauthorized" }], + }); } // check if webhook environment matches the surveys environment @@ -136,13 +131,11 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we return handleApiError(request, webhook.error); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId: webhook.ok ? webhook.data.environmentId : "", - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); + if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "webhook", issue: "unauthorized" }], + }); } const deletedWebhook = await deleteWebhook(params.webhookId); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts index 1314708eaf..278428e5b6 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts @@ -13,24 +13,24 @@ describe("getWebhooksQuery", () => { it("adds surveyIds condition when provided", () => { const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter; - const result = getWebhooksQuery(environmentId, params); + const result = getWebhooksQuery([environmentId], params); expect(result).toBeDefined(); expect(result?.where).toMatchObject({ - environmentId, + environmentId: { in: [environmentId] }, surveyIds: { hasSome: ["survey1"] }, }); }); it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => { vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any); - getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter); + getWebhooksQuery([environmentId], { surveyIds: ["survey1"] } as TGetWebhooksFilter); expect(pickCommonFilter).toHaveBeenCalled(); expect(buildCommonFilterQuery).toHaveBeenCalled(); }); it("buildCommonFilterQuery is not called if no baseFilter is picked", () => { vi.mocked(pickCommonFilter).mockReturnValue(undefined as any); - getWebhooksQuery(environmentId, {} as any); + getWebhooksQuery([environmentId], {} as any); expect(buildCommonFilterQuery).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/utils.ts b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts index 59716e4cd8..aac2e20fa6 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/utils.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts @@ -2,10 +2,10 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { Prisma } from "@prisma/client"; -export const getWebhooksQuery = (environmentId: string, params?: TGetWebhooksFilter) => { +export const getWebhooksQuery = (environmentIds: string[], params?: TGetWebhooksFilter) => { let query: Prisma.WebhookFindManyArgs = { where: { - environmentId, + environmentId: { in: environmentIds }, }, }; diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts index 7d9d15fbf3..1720fe5018 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -9,16 +9,16 @@ import { captureTelemetry } from "@formbricks/lib/telemetry"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const getWebhooks = async ( - environmentId: string, + environmentIds: string[], params: TGetWebhooksFilter ): Promise, ApiErrorResponseV2>> => { try { const [webhooks, count] = await prisma.$transaction([ prisma.webhook.findMany({ - ...getWebhooksQuery(environmentId, params), + ...getWebhooksQuery(environmentIds, params), }), prisma.webhook.count({ - where: getWebhooksQuery(environmentId, params).where, + where: getWebhooksQuery(environmentIds, params).where, }), ]); diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts index 994635e13e..cc5c0f3719 100644 --- a/apps/web/modules/api/v2/management/webhooks/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -1,10 +1,10 @@ import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client"; -import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization"; import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper"; import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook"; import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; export const GET = async (request: NextRequest) => @@ -23,9 +23,11 @@ export const GET = async (request: NextRequest) => }); } - const environmentId = authentication.environmentId; + const environemntIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); - const res = await getWebhooks(environmentId, query); + const res = await getWebhooks(environemntIds, query); if (res.ok) { return responses.successResponse(res.data); @@ -57,24 +59,13 @@ export const POST = async (request: NextRequest) => return handleApiError(request, environmentIdResult.error); } - const environmentId = environmentIdResult.data; - - if (body.environmentId !== environmentId) { + if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) { return handleApiError(request, { - type: "bad_request", - details: [{ field: "environmentId", issue: "does not match the surveys environment" }], + type: "forbidden", + details: [{ field: "environmentId", issue: "does not have permission to create webhook" }], }); } - const checkAuthorizationResult = await checkAuthorization({ - authentication, - environmentId, - }); - - if (!checkAuthorizationResult.ok) { - return handleApiError(request, checkAuthorizationResult.error); - } - const createWebhookResult = await createWebhook(body); if (!createWebhookResult.ok) { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index 800442c8d2..5b25d06195 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -1,10 +1,9 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { logger } from "@formbricks/logger"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { deleteContactAttributeKey, getContactAttributeKey, @@ -12,20 +11,22 @@ import { } from "./lib/contact-attribute-key"; import { ZContactAttributeKeyUpdateInput } from "./types/contact-attribute-keys"; -const fetchAndAuthorizeContactAttributeKey = async ( +async function fetchAndAuthorizeContactAttributeKey( + attributeKeyId: string, authentication: TAuthenticationApiKey, - contactAttributeKeyId: string -): Promise => { - const contactAttributeKey = await getContactAttributeKey(contactAttributeKeyId); - if (!contactAttributeKey) { - return null; + requiredPermission: "GET" | "PUT" | "DELETE" +) { + const attributeKey = await getContactAttributeKey(attributeKeyId); + if (!attributeKey) { + return { error: responses.notFoundResponse("Attribute Key", attributeKeyId) }; } - if (contactAttributeKey.environmentId !== authentication.environmentId) { - throw new Error("Unauthorized"); - } - return contactAttributeKey; -}; + if (!hasPermission(authentication.environmentPermissions, attributeKey.environmentId, requiredPermission)) { + return { error: responses.unauthorizedResponse() }; + } + + return { attributeKey }; +} export const GET = async ( request: Request, { params: paramsPromise }: { params: Promise<{ contactAttributeKeyId: string }> } @@ -35,20 +36,21 @@ export const GET = async ( const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const isContactsEnabled = await getIsContactsEnabled(); - if (!isContactsEnabled) { - return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); - } - - const contactAttributeKey = await fetchAndAuthorizeContactAttributeKey( + const result = await fetchAndAuthorizeContactAttributeKey( + params.contactAttributeKeyId, authentication, - params.contactAttributeKeyId + "GET" ); - if (contactAttributeKey) { - return responses.successResponse(contactAttributeKey); - } - return responses.notFoundResponse("Contact Attribute Key", params.contactAttributeKeyId); + if (result.error) return result.error; + + return responses.successResponse(result.attributeKey); } catch (error) { + if ( + error instanceof Error && + error.message === "Contacts are only enabled for Enterprise Edition, please upgrade." + ) { + return responses.forbiddenResponse(error.message); + } return handleErrorResponse(error); } }; @@ -62,24 +64,25 @@ export const DELETE = async ( const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const isContactsEnabled = await getIsContactsEnabled(); - if (!isContactsEnabled) { - return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); - } - - const contactAttributeKey = await fetchAndAuthorizeContactAttributeKey( + const result = await fetchAndAuthorizeContactAttributeKey( + params.contactAttributeKeyId, authentication, - params.contactAttributeKeyId + "DELETE" ); - if (!contactAttributeKey) { - return responses.notFoundResponse("Contact Attribute Key", params.contactAttributeKeyId); - } - if (contactAttributeKey.type === "default") { + + if (result.error) return result.error; + if (result.attributeKey.type === "default") { return responses.badRequestResponse("Default Contact Attribute Keys cannot be deleted"); } const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId); return responses.successResponse(deletedContactAttributeKey); } catch (error) { + if ( + error instanceof Error && + error.message === "Contacts are only enabled for Enterprise Edition, please upgrade." + ) { + return responses.forbiddenResponse(error.message); + } return handleErrorResponse(error); } }; @@ -93,18 +96,12 @@ export const PUT = async ( const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const isContactsEnabled = await getIsContactsEnabled(); - if (!isContactsEnabled) { - return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); - } - - const contactAttributeKey = await fetchAndAuthorizeContactAttributeKey( + const result = await fetchAndAuthorizeContactAttributeKey( + params.contactAttributeKeyId, authentication, - params.contactAttributeKeyId + "PUT" ); - if (!contactAttributeKey) { - return responses.notFoundResponse("Contact Attribute Key", params.contactAttributeKeyId); - } + if (result.error) return result.error; let contactAttributeKeyUpdate; try { @@ -130,6 +127,12 @@ export const PUT = async ( } return responses.internalServerErrorResponse("Some error ocured while updating action"); } catch (error) { + if ( + error instanceof Error && + error.message === "Contacts are only enabled for Enterprise Edition, please upgrade." + ) { + return responses.forbiddenResponse(error.message); + } return handleErrorResponse(error); } }; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts index 61c4abf7d0..d8351cee9e 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts @@ -14,12 +14,12 @@ import { import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; export const getContactAttributeKeys = reactCache( - (environmentId: string): Promise => + (environmentIds: string[]): Promise => cache( async () => { try { const contactAttributeKeys = await prisma.contactAttributeKey.findMany({ - where: { environmentId }, + where: { environmentId: { in: environmentIds } }, }); return contactAttributeKeys; @@ -30,9 +30,9 @@ export const getContactAttributeKeys = reactCache( throw error; } }, - [`getContactAttributeKeys-attribute-keys-management-api-${environmentId}`], + environmentIds.map((id) => `getContactAttributeKeys-attribute-keys-management-api-${id}`), { - tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)], + tags: environmentIds.map((id) => contactAttributeKeyCache.tag.byEnvironmentId(id)), } )() ); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts index 34928ba0e8..82bc5872dc 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts @@ -2,6 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; import { ZContactAttributeKeyCreateInput } from "./[contactAttributeKeyId]/types/contact-attribute-keys"; @@ -17,7 +18,12 @@ export const GET = async (request: Request) => { return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); } - const contactAttributeKeys = await getContactAttributeKeys(authentication.environmentId); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + + const contactAttributeKeys = await getContactAttributeKeys(environmentIds); + return responses.successResponse(contactAttributeKeys); } catch (error) { if (error instanceof DatabaseError) { @@ -54,9 +60,14 @@ export const POST = async (request: Request): Promise => { true ); } + const environmentId = contactAttibuteKeyInput.environmentId; + + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return responses.unauthorizedResponse(); + } const contactAttributeKey = await createContactAttributeKey( - authentication.environmentId, + environmentId, inputValidation.data.key, inputValidation.data.type ); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.ts index d72da67519..c23b6a0740 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.ts @@ -5,13 +5,15 @@ import { prisma } from "@formbricks/database"; import { cache } from "@formbricks/lib/cache"; import { DatabaseError } from "@formbricks/types/errors"; -export const getContactAttributes = reactCache((environmentId: string) => +export const getContactAttributes = reactCache((environmentIds: string[]) => cache( async () => { try { const contactAttributeKeys = await prisma.contactAttribute.findMany({ where: { - attributeKey: { environmentId }, + attributeKey: { + environmentId: { in: environmentIds }, + }, }, }); @@ -23,9 +25,9 @@ export const getContactAttributes = reactCache((environmentId: string) => throw error; } }, - [`getContactAttributes-contact-attributes-management-api-${environmentId}`], + environmentIds.map((id) => `getContactAttributes-contact-attributes-management-api-${id}`), { - tags: [contactAttributeCache.tag.byEnvironmentId(environmentId)], + tags: environmentIds.map((id) => contactAttributeCache.tag.byEnvironmentId(id)), } )() ); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/route.ts index 2be5c61955..14321555e1 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/route.ts @@ -14,8 +14,12 @@ export const GET = async (request: Request) => { return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); } - const contactAttributes = await getContactAttributes(authentication.environmentId); - return responses.successResponse(contactAttributes); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + + const attributes = await getContactAttributes(environmentIds); + return responses.successResponse(attributes); } catch (error) { if (error instanceof DatabaseError) { return responses.badRequestResponse(error.message); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts index ae78081fb7..4bc4457667 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts @@ -1,24 +1,28 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { AuthorizationError } from "@formbricks/types/errors"; import { deleteContact, getContact } from "./lib/contact"; // Please use the methods provided by the client API to update a person -const fetchAndAuthorizeContact = async (authentication: TAuthenticationApiKey, contactId: string) => { +const fetchAndAuthorizeContact = async ( + contactId: string, + authentication: TAuthenticationApiKey, + requiredPermission: "GET" | "PUT" | "DELETE" +) => { const contact = await getContact(contactId); if (!contact) { - return null; + return { error: responses.notFoundResponse("Contact", contactId) }; } - if (contact.environmentId !== authentication.environmentId) { - throw new AuthorizationError("Unauthorized"); + if (!hasPermission(authentication.environmentPermissions, contact.environmentId, requiredPermission)) { + return { error: responses.unauthorizedResponse() }; } - return contact; + return { contact }; }; export const GET = async ( @@ -35,12 +39,10 @@ export const GET = async ( return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); } - const contact = await fetchAndAuthorizeContact(authentication, params.contactId); - if (contact) { - return responses.successResponse(contact); - } + const result = await fetchAndAuthorizeContact(params.contactId, authentication, "GET"); + if (result.error) return result.error; - return responses.notFoundResponse("Contact", params.contactId); + return responses.successResponse(result.contact); } catch (error) { return handleErrorResponse(error); } @@ -60,10 +62,9 @@ export const DELETE = async ( return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); } - const contact = await fetchAndAuthorizeContact(authentication, params.contactId); - if (!contact) { - return responses.notFoundResponse("Contact", params.contactId); - } + const result = await fetchAndAuthorizeContact(params.contactId, authentication, "DELETE"); + if (result.error) return result.error; + await deleteContact(params.contactId); return responses.successResponse({ success: "Contact deleted successfully" }); } catch (error) { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.ts index db84f53918..494a0cf6a2 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.ts @@ -9,14 +9,14 @@ import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; export const getContacts = reactCache( - (environmentId: string): Promise => + (environmentIds: string[]): Promise => cache( async () => { - validateInputs([environmentId, ZId]); + validateInputs([environmentIds, ZId.array()]); try { const contacts = await prisma.contact.findMany({ - where: { environmentId }, + where: { environmentId: { in: environmentIds } }, }); return contacts; @@ -28,9 +28,9 @@ export const getContacts = reactCache( throw error; } }, - [`getContacts-management-api-${environmentId}`], + environmentIds.map((id) => `getContacts-management-api-${id}`), { - tags: [contactCache.tag.byEnvironmentId(environmentId)], + tags: environmentIds.map((id) => contactCache.tag.byEnvironmentId(id)), } )() ); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/route.ts index 8bef6f9d29..cbe5e44ce9 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contacts/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/route.ts @@ -14,7 +14,12 @@ export const GET = async (request: Request) => { return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade."); } - const contacts = await getContacts(authentication.environmentId!); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + + const contacts = await getContacts(environmentIds); + return responses.successResponse(contacts); } catch (error) { if (error instanceof DatabaseError) { diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts index 3cd4ea0223..14286c0f2c 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts @@ -4,6 +4,7 @@ import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authent import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact"; import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const PUT = async (request: Request) => authenticatedApiClient({ @@ -20,8 +21,22 @@ export const PUT = async (request: Request) => }); } + const environmentId = parsedInput.body?.environmentId; + + if (!environmentId) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "environmentId", issue: "missing" }], + }); + } + const { contacts } = parsedInput.body ?? { contacts: [] }; - const { environmentId } = authentication; + + if (!hasPermission(authentication.environmentPermissions, environmentId, "PUT")) { + return handleApiError(request, { + type: "unauthorized", + }); + } const emails = contacts.map( (contact) => contact.attributes.find((attr) => attr.attributeKey.key === "email")?.value! diff --git a/apps/web/modules/ee/contacts/types/contact.ts b/apps/web/modules/ee/contacts/types/contact.ts index 64c5b2ae5b..662cb1e692 100644 --- a/apps/web/modules/ee/contacts/types/contact.ts +++ b/apps/web/modules/ee/contacts/types/contact.ts @@ -123,6 +123,7 @@ export const ZContactBulkUploadContact = z.object({ export type TContactBulkUploadContact = z.infer; export const ZContactBulkUploadRequest = z.object({ + environmentId: z.string().cuid2(), contacts: z .array(ZContactBulkUploadContact) .max(1000, { message: "Maximum 1000 contacts allowed at a time." }) diff --git a/apps/web/modules/projects/settings/api-keys/actions.ts b/apps/web/modules/organization/settings/api-keys/actions.ts similarity index 60% rename from apps/web/modules/projects/settings/api-keys/actions.ts rename to apps/web/modules/organization/settings/api-keys/actions.ts index 66736045e1..8856319243 100644 --- a/apps/web/modules/projects/settings/api-keys/actions.ts +++ b/apps/web/modules/organization/settings/api-keys/actions.ts @@ -2,13 +2,8 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; -import { - getOrganizationIdFromApiKeyId, - getOrganizationIdFromEnvironmentId, - getProjectIdFromApiKeyId, - getProjectIdFromEnvironmentId, -} from "@/lib/utils/helper"; -import { createApiKey, deleteApiKey } from "@/modules/projects/settings/api-keys/lib/api-key"; +import { getOrganizationIdFromApiKeyId } from "@/lib/utils/helper"; +import { createApiKey, deleteApiKey } from "@/modules/organization/settings/api-keys/lib/api-key"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZApiKeyCreateInput } from "./types/api-keys"; @@ -28,11 +23,6 @@ export const deleteApiKeyAction = authenticatedActionClient type: "organization", roles: ["owner", "manager"], }, - { - type: "projectTeam", - minPermission: "manage", - projectId: await getProjectIdFromApiKeyId(parsedInput.id), - }, ], }); @@ -40,7 +30,7 @@ export const deleteApiKeyAction = authenticatedActionClient }); const ZCreateApiKeyAction = z.object({ - environmentId: ZId, + organizationId: ZId, apiKeyData: ZApiKeyCreateInput, }); @@ -49,19 +39,14 @@ export const createApiKeyAction = authenticatedActionClient .action(async ({ ctx, parsedInput }) => { await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), + organizationId: parsedInput.organizationId, access: [ { type: "organization", roles: ["owner", "manager"], }, - { - type: "projectTeam", - minPermission: "manage", - projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), - }, ], }); - return await createApiKey(parsedInput.environmentId, parsedInput.apiKeyData); + return await createApiKey(parsedInput.organizationId, ctx.user.id, parsedInput.apiKeyData); }); diff --git a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.test.tsx b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.test.tsx new file mode 100644 index 0000000000..0078f5eabb --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.test.tsx @@ -0,0 +1,251 @@ +import { ApiKeyPermission } from "@prisma/client"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { AddApiKeyModal } from "./add-api-key-modal"; + +// Mock the translate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, // Return the key as is for testing + }), +})); + +// Base project definition (customize as needed) +const baseProject = { + id: "project1", + name: "Project 1", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#000000" }, + }, + recontactDays: 0, + inAppSurveyBranding: false, + linkSurveyBranding: false, + config: { + channel: "link" as const, + industry: "saas" as const, + }, + placement: "bottomLeft" as const, + clickOutsideClose: true, + darkOverlay: false, + languages: [], +}; + +const mockProjects: TProject[] = [ + { + ...baseProject, + environments: [ + { + id: "env1", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + { + id: "env2", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + ], + } as TProject, + { + ...baseProject, + id: "project2", + name: "Project 2", + environments: [ + { + id: "env3", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project2", + appSetupCompleted: true, + }, + { + id: "env4", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project2", + appSetupCompleted: true, + }, + ], + } as TProject, +]; + +describe("AddApiKeyModal", () => { + const mockSetOpen = vi.fn(); + const mockOnSubmit = vi.fn(); + + const defaultProps = { + open: true, + setOpen: mockSetOpen, + onSubmit: mockOnSubmit, + projects: mockProjects, + isCreatingAPIKey: false, + }; + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders the modal with initial state", () => { + render(); + const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", { + selector: "div.text-xl", + }); + + expect(modalTitle).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack")).toBeInTheDocument(); + expect(screen.getByText("environments.project.api_keys.project_access")).toBeInTheDocument(); + }); + + it("handles label input", async () => { + render(); + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement; + + await userEvent.type(labelInput, "Test API Key"); + expect(labelInput.value).toBe("Test API Key"); + }); + + it("handles permission changes", async () => { + render(); + + // Open project dropdown for the first permission row + const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i }); + await userEvent.click(projectDropdowns[0]); + + // Wait for dropdown content and select 'Project 2' + const project2Option = await screen.findByRole("menuitem", { name: "Project 2" }); + await userEvent.click(project2Option); + + // Verify project selection by checking the updated button text + const updatedButton = await screen.findByRole("button", { name: "Project 2" }); + expect(updatedButton).toBeInTheDocument(); + }); + + it("adds and removes permissions", async () => { + render(); + + // Add new permission + const addButton = screen.getByRole("button", { name: /add_permission/i }); + await userEvent.click(addButton); + + // Verify new permission row is added + const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons + expect(deleteButtons).toHaveLength(2); + + // Remove the new permission + await userEvent.click(deleteButtons[1]); + + // Check that only the original permission row remains + expect(screen.getAllByRole("button", { name: "" })).toHaveLength(1); + }); + + it("submits form with correct data", async () => { + render(); + + // Fill in label + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement; + await userEvent.type(labelInput, "Test API Key"); + + // Click submit + const submitButton = screen.getByRole("button", { + name: "environments.project.api_keys.add_api_key", + }); + await userEvent.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith({ + label: "Test API Key", + environmentPermissions: [ + { + environmentId: "env1", + permission: ApiKeyPermission.read, + }, + ], + organizationAccess: { + accessControl: { + read: false, + write: false, + }, + }, + }); + }); + + it("submits form with correct data including organization access toggles", async () => { + render(); + + // Fill in label + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack"); + await userEvent.type(labelInput, "Test API Key"); + + // Toggle the first switch (read) under organizationAccess + const readSwitch = screen.getByTestId("organization-access-accessControl-read"); // first is read, second is write + await userEvent.click(readSwitch); // toggle 'read' to true + + // Submit form + const submitButton = screen.getByRole("button", { + name: "environments.project.api_keys.add_api_key", + }); + await userEvent.click(submitButton); + + expect(mockOnSubmit).toHaveBeenCalledWith({ + label: "Test API Key", + environmentPermissions: [ + { + environmentId: "env1", + permission: ApiKeyPermission.read, + }, + ], + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + }); + }); + + it("disables submit button when label is empty", async () => { + render(); + const submitButton = screen.getByRole("button", { + name: "environments.project.api_keys.add_api_key", + }); + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack"); + + // Initially disabled + expect(submitButton).toBeDisabled(); + + // After typing, it should be enabled + await userEvent.type(labelInput, "Test"); + expect(submitButton).not.toBeDisabled(); + }); + + it("closes modal and resets form on cancel", async () => { + render(); + + // Type something into the label + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement; + await userEvent.type(labelInput, "Test API Key"); + + // Click the cancel button + const cancelButton = screen.getByRole("button", { name: "common.cancel" }); + await userEvent.click(cancelButton); + + // Verify modal is closed and form is reset + expect(mockSetOpen).toHaveBeenCalledWith(false); + expect(labelInput.value).toBe(""); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx new file mode 100644 index 0000000000..27f5e32bb9 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx @@ -0,0 +1,422 @@ +"use client"; + +import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils"; +import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys"; +import { Button } from "@/modules/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/modules/ui/components/dropdown-menu"; +import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; +import { Modal } from "@/modules/ui/components/modal"; +import { Switch } from "@/modules/ui/components/switch"; +import { ApiKeyPermission } from "@prisma/client"; +import { useTranslate } from "@tolgee/react"; +import { AlertTriangleIcon, ChevronDownIcon, Trash2Icon } from "lucide-react"; +import { Fragment, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "react-hot-toast"; +import { TOrganizationAccess } from "@formbricks/types/api-key"; + +interface AddApiKeyModalProps { + open: boolean; + setOpen: (v: boolean) => void; + onSubmit: (data: { + label: string; + environmentPermissions: Array<{ environmentId: string; permission: ApiKeyPermission }>; + organizationAccess: TOrganizationAccess; + }) => Promise; + projects: TOrganizationProject[]; + isCreatingAPIKey: boolean; +} + +interface ProjectOption { + id: string; + name: string; +} + +interface PermissionRecord { + projectId: string; + environmentId: string; + permission: ApiKeyPermission; + projectName: string; + environmentType: string; +} + +const permissionOptions: ApiKeyPermission[] = [ + ApiKeyPermission.read, + ApiKeyPermission.write, + ApiKeyPermission.manage, +]; + +export const AddApiKeyModal = ({ + open, + setOpen, + onSubmit, + projects, + isCreatingAPIKey, +}: AddApiKeyModalProps) => { + const { t } = useTranslate(); + const { register, getValues, handleSubmit, reset, watch } = useForm<{ label: string }>(); + const apiKeyLabel = watch("label"); + const defaultOrganizationAccess: TOrganizationAccess = { + accessControl: { + read: false, + write: false, + }, + }; + + const [selectedOrganizationAccess, setSelectedOrganizationAccess] = + useState(defaultOrganizationAccess); + + const getInitialPermissions = () => { + if (projects.length > 0 && projects[0].environments.length > 0) { + return { + "permission-0": { + projectId: projects[0].id, + environmentId: projects[0].environments[0].id, + permission: ApiKeyPermission.read, + projectName: projects[0].name, + environmentType: projects[0].environments[0].type, + }, + }; + } + return {} as Record; + }; + + // Initialize with one permission by default + const [selectedPermissions, setSelectedPermissions] = useState>(() => + getInitialPermissions() + ); + + const projectOptions: ProjectOption[] = projects.map((project) => ({ + id: project.id, + name: project.name, + })); + + const removePermission = (index: number) => { + const updatedPermissions = { ...selectedPermissions }; + delete updatedPermissions[`permission-${index}`]; + setSelectedPermissions(updatedPermissions); + }; + + const addPermission = () => { + const newIndex = Object.keys(selectedPermissions).length; + if (projects.length > 0 && projects[0].environments.length > 0) { + const initialPermission = getInitialPermissions()["permission-0"]; + if (initialPermission) { + setSelectedPermissions({ + ...selectedPermissions, + [`permission-${newIndex}`]: initialPermission, + }); + } + } + }; + + const updatePermission = (key: string, field: string, value: string) => { + const project = projects.find((p) => p.id === selectedPermissions[key].projectId); + const environment = project?.environments.find((env) => env.id === value); + + setSelectedPermissions({ + ...selectedPermissions, + [key]: { + ...selectedPermissions[key], + [field]: value, + ...(field === "environmentId" && environment ? { environmentType: environment.type } : {}), + }, + }); + }; + + // Update environment when project changes + const updateProjectAndEnvironment = (key: string, projectId: string) => { + const project = projects.find((p) => p.id === projectId); + if (project && project.environments.length > 0) { + const environment = project.environments[0]; + setSelectedPermissions({ + ...selectedPermissions, + [key]: { + ...selectedPermissions[key], + projectId, + environmentId: environment.id, + projectName: project.name, + environmentType: environment.type, + }, + }); + } + }; + + const checkForDuplicatePermissions = () => { + const permissions = Object.values(selectedPermissions); + const uniquePermissions = new Set(permissions.map((p) => `${p.projectId}-${p.environmentId}`)); + return uniquePermissions.size !== permissions.length; + }; + + const submitAPIKey = async () => { + const data = getValues(); + + if (checkForDuplicatePermissions()) { + toast.error(t("environments.project.api_keys.duplicate_access")); + return; + } + + // Convert permissions to the format expected by the API + const environmentPermissions = Object.values(selectedPermissions).map((permission) => ({ + environmentId: permission.environmentId, + permission: permission.permission, + })); + + await onSubmit({ + label: data.label, + environmentPermissions, + organizationAccess: selectedOrganizationAccess, + }); + + reset(); + setSelectedPermissions(getInitialPermissions()); + setSelectedOrganizationAccess(defaultOrganizationAccess); + }; + + // Get environment options for a project + const getEnvironmentOptionsForProject = (projectId: string) => { + const project = projects.find((p) => p.id === projectId); + return project?.environments || []; + }; + + const isSubmitDisabled = () => { + // Check if label is empty or only whitespace + if (!apiKeyLabel?.trim()) { + return true; + } + // Check if there are any valid permissions + if (Object.keys(selectedPermissions).length === 0) { + return true; + } + return false; + }; + + const setSelectedOrganizationAccessValue = (key: string, accessType: string, value: boolean) => { + setSelectedOrganizationAccess((prev) => ({ + ...prev, + [key]: { + ...prev[key], + [accessType]: value, + }, + })); + }; + + return ( + +
+
+
+
+
+ {t("environments.project.api_keys.add_api_key")} +
+
+
+
+
+
+
+
+ + value.trim() !== "" })} + /> +
+ +
+ +
+ {/* Permission rows */} + {Object.keys(selectedPermissions).map((key) => { + const permissionIndex = parseInt(key.split("-")[1]); + const permission = selectedPermissions[key]; + return ( +
+ {/* Project dropdown */} +
+ + + + + + {projectOptions.map((option) => ( + { + updateProjectAndEnvironment(key, option.id); + }}> + {option.name} + + ))} + + +
+ + {/* Environment dropdown */} +
+ + + + + + {getEnvironmentOptionsForProject(permission.projectId).map((env) => ( + { + updatePermission(key, "environmentId", env.id); + }}> + {env.type} + + ))} + + +
+ + {/* Permission level dropdown */} +
+ + + + + + {permissionOptions.map((option) => ( + { + updatePermission(key, "permission", option); + }}> + {option} + + ))} + + +
+ + {/* Delete button */} + +
+ ); + })} + + {/* Add permission button */} + +
+
+ +
+ +
+
+
+ Read + Write + + {Object.keys(selectedOrganizationAccess).map((key) => ( + +
{t(getOrganizationAccessKeyDisplayName(key))}
+
+ + setSelectedOrganizationAccessValue(key, "read", newVal) + } + /> +
+
+ + setSelectedOrganizationAccessValue(key, "write", newVal) + } + /> +
+
+ ))} +
+
+
+ +
+ +

{t("environments.project.api_keys.api_key_security_warning")}

+
+
+
+
+
+ + +
+
+
+
+
+ ); +}; diff --git a/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx b/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx new file mode 100644 index 0000000000..05d046f717 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx @@ -0,0 +1,166 @@ +import "@testing-library/jest-dom/vitest"; +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { getApiKeysWithEnvironmentPermissions } from "../lib/api-key"; +import { ApiKeyList } from "./api-key-list"; + +// Mock the getApiKeysWithEnvironmentPermissions function +vi.mock("../lib/api-key", () => ({ + getApiKeysWithEnvironmentPermissions: vi.fn(), +})); + +// Mock @formbricks/lib/constants +vi.mock("@formbricks/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", +})); + +// Mock @formbricks/lib/env +vi.mock("@formbricks/lib/env", () => ({ + env: { + IS_FORMBRICKS_CLOUD: "0", + }, +})); + +const baseProject = { + id: "project1", + name: "Project 1", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#000000" }, + }, + recontactDays: 0, + inAppSurveyBranding: false, + linkSurveyBranding: false, + config: { + channel: "link" as const, + industry: "saas" as const, + }, + placement: "bottomLeft" as const, + clickOutsideClose: true, + darkOverlay: false, + languages: [], +}; + +const mockProjects: TProject[] = [ + { + ...baseProject, + environments: [ + { + id: "env1", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + { + id: "env2", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + ], + }, +]; + +const mockApiKeys = [ + { + id: "key1", + hashedKey: "hashed1", + label: "Test Key 1", + createdAt: new Date(), + lastUsedAt: null, + organizationId: "org1", + createdBy: "user1", + }, + { + id: "key2", + hashedKey: "hashed2", + label: "Test Key 2", + createdAt: new Date(), + lastUsedAt: null, + organizationId: "org1", + createdBy: "user1", + }, +]; + +describe("ApiKeyList", () => { + it("renders EditAPIKeys with correct props", async () => { + // Mock the getApiKeysWithEnvironmentPermissions function to return our mock data + (getApiKeysWithEnvironmentPermissions as unknown as ReturnType).mockResolvedValue( + mockApiKeys + ); + + const props = { + organizationId: "org1", + locale: "en-US" as const, + isReadOnly: false, + projects: mockProjects, + }; + + const { container } = render(await ApiKeyList(props)); + + // Verify that EditAPIKeys is rendered with the correct props + expect(getApiKeysWithEnvironmentPermissions).toHaveBeenCalledWith("org1"); + expect(container).toBeInTheDocument(); + }); + + it("handles empty api keys", async () => { + // Mock the getApiKeysWithEnvironmentPermissions function to return empty array + (getApiKeysWithEnvironmentPermissions as unknown as ReturnType).mockResolvedValue([]); + + const props = { + organizationId: "org1", + locale: "en-US" as const, + isReadOnly: false, + projects: mockProjects, + }; + + const { container } = render(await ApiKeyList(props)); + + // Verify that EditAPIKeys is rendered even with empty api keys + expect(getApiKeysWithEnvironmentPermissions).toHaveBeenCalledWith("org1"); + expect(container).toBeInTheDocument(); + }); + + it("passes isReadOnly prop correctly", async () => { + (getApiKeysWithEnvironmentPermissions as unknown as ReturnType).mockResolvedValue( + mockApiKeys + ); + + const props = { + organizationId: "org1", + locale: "en-US" as const, + isReadOnly: true, + projects: mockProjects, + }; + + const { container } = render(await ApiKeyList(props)); + + expect(getApiKeysWithEnvironmentPermissions).toHaveBeenCalledWith("org1"); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/components/api-key-list.tsx b/apps/web/modules/organization/settings/api-keys/components/api-key-list.tsx new file mode 100644 index 0000000000..84525a2fe7 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/api-key-list.tsx @@ -0,0 +1,25 @@ +import { getApiKeysWithEnvironmentPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; +import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys"; +import { TUserLocale } from "@formbricks/types/user"; +import { EditAPIKeys } from "./edit-api-keys"; + +interface ApiKeyListProps { + organizationId: string; + locale: TUserLocale; + isReadOnly: boolean; + projects: TOrganizationProject[]; +} + +export const ApiKeyList = async ({ organizationId, locale, isReadOnly, projects }: ApiKeyListProps) => { + const apiKeys = await getApiKeysWithEnvironmentPermissions(organizationId); + + return ( + + ); +}; diff --git a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx new file mode 100644 index 0000000000..c8ba9e5be3 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx @@ -0,0 +1,257 @@ +import { ApiKeyPermission } from "@prisma/client"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { createApiKeyAction, deleteApiKeyAction } from "../actions"; +import { TApiKeyWithEnvironmentPermission } from "../types/api-keys"; +import { EditAPIKeys } from "./edit-api-keys"; + +// Mock the actions +vi.mock("../actions", () => ({ + createApiKeyAction: vi.fn(), + deleteApiKeyAction: vi.fn(), +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock the translate hook from @tolgee/react +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, // simply return the key + }), +})); + +// Base project setup +const baseProject = {}; + +// Example project data +const mockProjects: TProject[] = [ + { + ...baseProject, + id: "project1", + name: "Project 1", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#000000" }, + }, + config: { + channel: "link" as const, + industry: "saas" as const, + }, + environments: [ + { + id: "env1", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + { + id: "env2", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + ], + } as TProject, +]; + +// Example API keys +const mockApiKeys: TApiKeyWithEnvironmentPermission[] = [ + { + id: "key1", + label: "Test Key 1", + createdAt: new Date(), + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + apiKeyEnvironments: [ + { + environmentId: "env1", + permission: ApiKeyPermission.read, + }, + ], + }, + { + id: "key2", + label: "Test Key 2", + createdAt: new Date(), + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + apiKeyEnvironments: [ + { + environmentId: "env2", + permission: ApiKeyPermission.read, + }, + ], + }, +]; + +describe("EditAPIKeys", () => { + // Reset environment after each test + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const defaultProps = { + organizationId: "org1", + apiKeys: mockApiKeys, + locale: "en-US" as const, + isReadOnly: false, + projects: mockProjects, + }; + + it("renders the API keys list", () => { + render(); + expect(screen.getByText("common.label")).toBeInTheDocument(); + expect(screen.getByText("Test Key 1")).toBeInTheDocument(); + expect(screen.getByText("Test Key 2")).toBeInTheDocument(); + }); + + it("renders empty state when no API keys", () => { + render(); + expect(screen.getByText("environments.project.api_keys.no_api_keys_yet")).toBeInTheDocument(); + }); + + it("shows add API key button when not readonly", () => { + render(); + expect( + screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" }) + ).toBeInTheDocument(); + }); + + it("hides add API key button when readonly", () => { + render(); + expect( + screen.queryByRole("button", { name: "environments.settings.api_keys.add_api_key" }) + ).not.toBeInTheDocument(); + }); + + it("opens add API key modal when clicking add button", async () => { + render(); + const addButton = screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" }); + await userEvent.click(addButton); + + // Look for the modal title specifically + const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", { + selector: "div.text-xl", + }); + expect(modalTitle).toBeInTheDocument(); + }); + + it("handles API key deletion", async () => { + (deleteApiKeyAction as unknown as ReturnType).mockResolvedValue({ data: true }); + + render(); + const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons + + // Click delete button for first API key + await userEvent.click(deleteButtons[0]); + const confirmDeleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(confirmDeleteButton); + + expect(deleteApiKeyAction).toHaveBeenCalledWith({ id: "key1" }); + expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_deleted"); + }); + + it("handles API key creation", async () => { + const newApiKey: TApiKeyWithEnvironmentPermission = { + id: "key3", + label: "New Key", + createdAt: new Date(), + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + apiKeyEnvironments: [ + { + environmentId: "env2", + permission: ApiKeyPermission.read, + }, + ], + }; + + (createApiKeyAction as unknown as ReturnType).mockResolvedValue({ data: newApiKey }); + + render(); + + // Open add modal + const addButton = screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" }); + await userEvent.click(addButton); + + // Fill in form + const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack"); + await userEvent.type(labelInput, "New Key"); + + // Optionally toggle the read switch + const readSwitch = screen.getByTestId("organization-access-accessControl-read"); // first is read, second is write + await userEvent.click(readSwitch); // toggle 'read' to true + + // Submit form + const submitButton = screen.getByRole("button", { name: "environments.project.api_keys.add_api_key" }); + await userEvent.click(submitButton); + + expect(createApiKeyAction).toHaveBeenCalledWith({ + organizationId: "org1", + apiKeyData: { + label: "New Key", + environmentPermissions: [{ environmentId: "env1", permission: ApiKeyPermission.read }], + organizationAccess: { + accessControl: { read: true, write: false }, + }, + }, + }); + + expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_created"); + }); + + it("handles copy to clipboard", async () => { + // Mock the clipboard writeText method + const writeText = vi.fn(); + Object.assign(navigator, { + clipboard: { + writeText, + }, + }); + + // Provide an API key that has an actualKey + const apiKeyWithActual = { + ...mockApiKeys[0], + actualKey: "test-api-key-123", + } as TApiKeyWithEnvironmentPermission & { actualKey: string }; + + render(); + + // Find the copy icon button by testid + const copyButton = screen.getByTestId("copy-button"); + await userEvent.click(copyButton); + + expect(writeText).toHaveBeenCalledWith("test-api-key-123"); + expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_copied_to_clipboard"); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx new file mode 100644 index 0000000000..a11ce6e60a --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/components/view-permission-modal"; +import { + TApiKeyWithEnvironmentPermission, + TOrganizationProject, +} from "@/modules/organization/settings/api-keys/types/api-keys"; +import { Button } from "@/modules/ui/components/button"; +import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; +import { ApiKeyPermission } from "@prisma/client"; +import { useTranslate } from "@tolgee/react"; +import { FilesIcon, TrashIcon } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { timeSince } from "@formbricks/lib/time"; +import { TOrganizationAccess } from "@formbricks/types/api-key"; +import { TUserLocale } from "@formbricks/types/user"; +import { createApiKeyAction, deleteApiKeyAction } from "../actions"; +import { AddApiKeyModal } from "./add-api-key-modal"; + +interface EditAPIKeysProps { + organizationId: string; + apiKeys: TApiKeyWithEnvironmentPermission[]; + locale: TUserLocale; + isReadOnly: boolean; + projects: TOrganizationProject[]; +} + +export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, projects }: EditAPIKeysProps) => { + const { t } = useTranslate(); + const [isAddAPIKeyModalOpen, setIsAddAPIKeyModalOpen] = useState(false); + const [isDeleteKeyModalOpen, setIsDeleteKeyModalOpen] = useState(false); + const [apiKeysLocal, setApiKeysLocal] = + useState<(TApiKeyWithEnvironmentPermission & { actualKey?: string })[]>(apiKeys); + const [activeKey, setActiveKey] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [viewPermissionsOpen, setViewPermissionsOpen] = useState(false); + + const handleOpenDeleteKeyModal = (e, apiKey) => { + e.preventDefault(); + setActiveKey(apiKey); + setIsDeleteKeyModalOpen(true); + }; + + const handleDeleteKey = async () => { + if (!activeKey) return; + setIsLoading(true); + const deleteApiKeyResponse = await deleteApiKeyAction({ id: activeKey.id }); + if (deleteApiKeyResponse?.data) { + const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || []; + setApiKeysLocal(updatedApiKeys); + toast.success(t("environments.project.api_keys.api_key_deleted")); + setIsDeleteKeyModalOpen(false); + setIsLoading(false); + } else { + toast.error(t("environments.project.api_keys.unable_to_delete_api_key")); + setIsDeleteKeyModalOpen(false); + setIsLoading(false); + } + }; + + const handleAddAPIKey = async (data: { + label: string; + environmentPermissions: Array<{ environmentId: string; permission: ApiKeyPermission }>; + organizationAccess: TOrganizationAccess; + }): Promise => { + setIsLoading(true); + const createApiKeyResponse = await createApiKeyAction({ + organizationId: organizationId, + apiKeyData: { + label: data.label, + environmentPermissions: data.environmentPermissions, + organizationAccess: data.organizationAccess, + }, + }); + + if (createApiKeyResponse?.data) { + const updatedApiKeys = [...apiKeysLocal, createApiKeyResponse.data]; + setApiKeysLocal(updatedApiKeys); + setIsLoading(false); + toast.success(t("environments.project.api_keys.api_key_created")); + } else { + setIsLoading(false); + const errorMessage = getFormattedErrorMessage(createApiKeyResponse); + toast.error(errorMessage); + } + + setIsAddAPIKeyModalOpen(false); + }; + + const ApiKeyDisplay = ({ apiKey }) => { + const copyToClipboard = () => { + navigator.clipboard.writeText(apiKey); + toast.success(t("environments.project.api_keys.api_key_copied_to_clipboard")); + }; + + if (!apiKey) { + return {t("environments.project.api_keys.secret")}; + } + + return ( +
+ {apiKey} +
+ { + e.stopPropagation(); + copyToClipboard(); + }} + data-testid="copy-button" + /> +
+
+ ); + }; + + return ( +
+
+
+
{t("common.label")}
+
+ {t("environments.project.api_keys.api_key")} +
+
{t("common.created_at")}
+
+
+
+ {apiKeysLocal?.length === 0 ? ( +
+ {t("environments.project.api_keys.no_api_keys_yet")} +
+ ) : ( + apiKeysLocal?.map((apiKey) => ( +
{ + setActiveKey(apiKey); + setViewPermissionsOpen(true); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveKey(apiKey); + setViewPermissionsOpen(true); + } + }} + tabIndex={0} + key={apiKey.id}> +
{apiKey.label}
+
+ +
+
+ {timeSince(apiKey.createdAt.toString(), locale)} +
+ {!isReadOnly && ( +
+ +
+ )} +
+ )) + )} +
+
+ + {!isReadOnly && ( +
+ +
+ )} + + {activeKey && ( + + )} + +
+ ); +}; diff --git a/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.test.tsx b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.test.tsx new file mode 100644 index 0000000000..40ac5a3109 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.test.tsx @@ -0,0 +1,160 @@ +import { ApiKeyPermission } from "@prisma/client"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { TApiKeyWithEnvironmentPermission } from "../types/api-keys"; +import { ViewPermissionModal } from "./view-permission-modal"; + +// Mock the translate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Base project setup +const baseProject = {}; + +// Example project data +const mockProjects: TProject[] = [ + { + ...baseProject, + id: "project1", + name: "Project 1", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#000000" }, + }, + config: { + channel: "link" as const, + industry: "saas" as const, + }, + environments: [ + { + id: "env1", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + { + id: "env2", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + ], + } as TProject, +]; + +// Example API key with permissions +const mockApiKey: TApiKeyWithEnvironmentPermission = { + id: "key1", + label: "Test Key 1", + createdAt: new Date(), + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + apiKeyEnvironments: [ + { + environmentId: "env1", + permission: ApiKeyPermission.read, + }, + { + environmentId: "env2", + permission: ApiKeyPermission.write, + }, + ], +}; + +// API key with additional organization access +const mockApiKeyWithOrgAccess = { + ...mockApiKey, + organizationAccess: { + accessControl: { read: true, write: false }, + otherAccess: { read: false, write: true }, + }, +}; + +// API key with no environment permissions +const apiKeyWithoutPermissions = { + ...mockApiKey, + apiKeyEnvironments: [], +}; + +describe("ViewPermissionModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const defaultProps = { + open: true, + setOpen: vi.fn(), + projects: mockProjects, + apiKey: mockApiKey, + }; + + it("renders the modal with correct title", () => { + render(); + // Check the localized text for the modal's title + expect(screen.getByText("environments.project.api_keys.api_key")).toBeInTheDocument(); + }); + + it("renders all permissions for the API key", () => { + render(); + // The same key has two environment permissions + const projectNames = screen.getAllByText("Project 1"); + expect(projectNames).toHaveLength(2); // once for each permission + expect(screen.getByText("production")).toBeInTheDocument(); + expect(screen.getByText("development")).toBeInTheDocument(); + expect(screen.getByText("read")).toBeInTheDocument(); + expect(screen.getByText("write")).toBeInTheDocument(); + }); + + it("displays correct project and environment names", () => { + render(); + // Check for 'Project 1', 'production', 'development' + const projectNames = screen.getAllByText("Project 1"); + expect(projectNames).toHaveLength(2); + expect(screen.getByText("production")).toBeInTheDocument(); + expect(screen.getByText("development")).toBeInTheDocument(); + }); + + it("displays correct permission levels", () => { + render(); + // Check if permission levels 'read' and 'write' appear + expect(screen.getByText("read")).toBeInTheDocument(); + expect(screen.getByText("write")).toBeInTheDocument(); + }); + + it("handles API key with no permissions", () => { + render(); + // Ensure environment/permission section is empty + expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); + expect(screen.queryByText("production")).not.toBeInTheDocument(); + expect(screen.queryByText("development")).not.toBeInTheDocument(); + }); + + it("displays organizationAccess toggles", () => { + render(); + + expect(screen.getByTestId("organization-access-accessControl-read")).toBeChecked(); + expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled(); + expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked(); + expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled(); + expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked(); + expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked(); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx new file mode 100644 index 0000000000..c016d9bbb8 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils"; +import { + TApiKeyWithEnvironmentPermission, + TOrganizationProject, +} from "@/modules/organization/settings/api-keys/types/api-keys"; +import { DropdownMenu, DropdownMenuTrigger } from "@/modules/ui/components/dropdown-menu"; +import { Label } from "@/modules/ui/components/label"; +import { Modal } from "@/modules/ui/components/modal"; +import { Switch } from "@/modules/ui/components/switch"; +import { useTranslate } from "@tolgee/react"; +import { Fragment } from "react"; +import { TOrganizationAccess } from "@formbricks/types/api-key"; + +interface ViewPermissionModalProps { + open: boolean; + setOpen: (v: boolean) => void; + apiKey: TApiKeyWithEnvironmentPermission; + projects: TOrganizationProject[]; +} + +export const ViewPermissionModal = ({ open, setOpen, apiKey, projects }: ViewPermissionModalProps) => { + const { t } = useTranslate(); + const organizationAccess = apiKey.organizationAccess as TOrganizationAccess; + + const getProjectName = (environmentId: string) => { + return projects.find((project) => project.environments.find((env) => env.id === environmentId))?.name; + }; + + const getEnvironmentName = (environmentId: string) => { + return projects + .find((project) => project.environments.find((env) => env.id === environmentId)) + ?.environments.find((env) => env.id === environmentId)?.type; + }; + + return ( + +
+
+
+
+
+ {t("environments.project.api_keys.api_key")} +
+
+
+
+
+
+
+
+ +
+ {/* Permission rows */} + {apiKey.apiKeyEnvironments?.map((permission) => { + return ( +
+ {/* Project dropdown */} +
+ + + + + +
+ + {/* Environment dropdown */} +
+ + + + + +
+ + {/* Permission level dropdown */} +
+ + + + + +
+
+ ); + })} +
+
+ +
+ +
+
+
+ Read + Write + + {Object.keys(organizationAccess).map((key) => ( + +
{t(getOrganizationAccessKeyDisplayName(key))}
+
+ +
+
+ +
+
+ ))} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts new file mode 100644 index 0000000000..45ad8f1b77 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts @@ -0,0 +1,178 @@ +import "server-only"; +import { apiKeyCache } from "@/lib/cache/api-key"; +import { + TApiKeyCreateInput, + TApiKeyWithEnvironmentPermission, + ZApiKeyCreateInput, +} from "@/modules/organization/settings/api-keys/types/api-keys"; +import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client"; +import { createHash, randomBytes } from "crypto"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { TOrganizationAccess } from "@formbricks/types/api-key"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getApiKeysWithEnvironmentPermissions = reactCache( + async (organizationId: string): Promise => + cache( + async () => { + validateInputs([organizationId, ZId]); + + try { + const apiKeys = await prisma.apiKey.findMany({ + where: { + organizationId, + }, + select: { + id: true, + label: true, + createdAt: true, + organizationAccess: true, + apiKeyEnvironments: { + select: { + environmentId: true, + permission: true, + }, + }, + }, + }); + return apiKeys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`getApiKeysWithEnvironments-${organizationId}`], + { + tags: [apiKeyCache.tag.byOrganizationId(organizationId)], + } + )() +); + +// Get API key with its permissions from a raw API key +export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => { + const hashedKey = hashApiKey(apiKey); + return cache( + async () => { + // Look up the API key in the new structure + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey, + }, + include: { + apiKeyEnvironments: { + include: { + environment: true, + }, + }, + }, + }); + + if (!apiKeyData) return null; + + // Update the last used timestamp + await prisma.apiKey.update({ + where: { + id: apiKeyData.id, + }, + data: { + lastUsedAt: new Date(), + }, + }); + + return apiKeyData; + }, + [`getApiKeyWithPermissions-${apiKey}`], + { + tags: [apiKeyCache.tag.byHashedKey(hashedKey)], + } + )(); +}); + +export const deleteApiKey = async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const deletedApiKeyData = await prisma.apiKey.delete({ + where: { + id: id, + }, + }); + + apiKeyCache.revalidate({ + id: deletedApiKeyData.id, + hashedKey: deletedApiKeyData.hashedKey, + organizationId: deletedApiKeyData.organizationId, + }); + + return deletedApiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); + +export const createApiKey = async ( + organizationId: string, + userId: string, + apiKeyData: TApiKeyCreateInput & { + environmentPermissions?: Array<{ environmentId: string; permission: ApiKeyPermission }>; + organizationAccess: TOrganizationAccess; + } +): Promise => { + validateInputs([organizationId, ZId], [apiKeyData, ZApiKeyCreateInput]); + try { + const key = randomBytes(16).toString("hex"); + const hashedKey = hashApiKey(key); + + // Extract environmentPermissions from apiKeyData + const { environmentPermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData; + + // Create the API key + const result = await prisma.apiKey.create({ + data: { + ...apiKeyDataWithoutPermissions, + hashedKey, + createdBy: userId, + organization: { connect: { id: organizationId } }, + organizationAccess, + ...(environmentPermissions && environmentPermissions.length > 0 + ? { + apiKeyEnvironments: { + create: environmentPermissions.map((envPerm) => ({ + environmentId: envPerm.environmentId, + permission: envPerm.permission, + })), + }, + } + : {}), + }, + include: { + apiKeyEnvironments: true, + }, + }); + + apiKeyCache.revalidate({ + id: result.id, + hashedKey: result.hashedKey, + organizationId: result.organizationId, + }); + + return { ...result, actualKey: key }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts new file mode 100644 index 0000000000..87e3b2dcc5 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts @@ -0,0 +1,194 @@ +import { apiKeyCache } from "@/lib/cache/api-key"; +import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TApiKeyWithEnvironmentPermission } from "../types/api-keys"; +import { createApiKey, deleteApiKey, getApiKeysWithEnvironmentPermissions } from "./api-key"; + +const mockApiKey: ApiKey = { + id: "apikey123", + label: "Test API Key", + hashedKey: "hashed_key_value", + createdAt: new Date(), + createdBy: "user123", + organizationId: "org123", + lastUsedAt: null, + organizationAccess: { + accessControl: { + read: false, + write: false, + }, + }, +}; + +const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = { + ...mockApiKey, + apiKeyEnvironments: [ + { + environmentId: "env123", + permission: ApiKeyPermission.manage, + }, + ], +}; + +// Mock modules before tests +vi.mock("@formbricks/database", () => ({ + prisma: { + apiKey: { + findMany: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/api-key", () => ({ + apiKeyCache: { + revalidate: vi.fn(), + tag: { + byOrganizationId: vi.fn(), + }, + }, +})); + +vi.mock("crypto", () => ({ + randomBytes: () => ({ + toString: () => "generated_key", + }), + createHash: () => ({ + update: vi.fn().mockReturnThis(), + digest: vi.fn().mockReturnValue("hashed_key_value"), + }), +})); + +describe("API Key Management", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getApiKeysWithEnvironmentPermissions", () => { + it("retrieves API keys successfully", async () => { + vi.mocked(prisma.apiKey.findMany).mockResolvedValueOnce([mockApiKeyWithEnvironments]); + vi.mocked(apiKeyCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + const result = await getApiKeysWithEnvironmentPermissions("clj28r6va000409j3ep7h8xzk"); + + expect(result).toEqual([mockApiKeyWithEnvironments]); + expect(prisma.apiKey.findMany).toHaveBeenCalledWith({ + where: { + organizationId: "clj28r6va000409j3ep7h8xzk", + }, + select: { + apiKeyEnvironments: { + select: { + environmentId: true, + permission: true, + }, + }, + createdAt: true, + id: true, + label: true, + organizationAccess: true, + }, + }); + }); + + it("throws DatabaseError on prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2002", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.apiKey.findMany).mockRejectedValueOnce(errToThrow); + vi.mocked(apiKeyCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + await expect(getApiKeysWithEnvironmentPermissions("org123")).rejects.toThrow(DatabaseError); + }); + }); + + describe("deleteApiKey", () => { + it("deletes an API key successfully", async () => { + vi.mocked(prisma.apiKey.delete).mockResolvedValueOnce(mockApiKey); + + const result = await deleteApiKey(mockApiKey.id); + + expect(result).toEqual(mockApiKey); + expect(prisma.apiKey.delete).toHaveBeenCalledWith({ + where: { + id: mockApiKey.id, + }, + }); + expect(apiKeyCache.revalidate).toHaveBeenCalled(); + }); + + it("throws DatabaseError on prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2002", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.apiKey.delete).mockRejectedValueOnce(errToThrow); + + await expect(deleteApiKey(mockApiKey.id)).rejects.toThrow(DatabaseError); + }); + }); + + describe("createApiKey", () => { + const mockApiKeyData = { + label: "Test API Key", + organizationAccess: { + accessControl: { + read: false, + write: false, + }, + }, + }; + + const mockApiKeyWithEnvironments = { + ...mockApiKey, + apiKeyEnvironments: [ + { + id: "env-perm-123", + apiKeyId: "apikey123", + environmentId: "env123", + permission: ApiKeyPermission.manage, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }; + + it("creates an API key successfully", async () => { + vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey); + + const result = await createApiKey("org123", "user123", mockApiKeyData); + + expect(result).toEqual({ ...mockApiKey, actualKey: "generated_key" }); + expect(prisma.apiKey.create).toHaveBeenCalled(); + expect(apiKeyCache.revalidate).toHaveBeenCalled(); + }); + + it("creates an API key with environment permissions successfully", async () => { + vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKeyWithEnvironments); + + const result = await createApiKey("org123", "user123", { + ...mockApiKeyData, + environmentPermissions: [{ environmentId: "env123", permission: ApiKeyPermission.manage }], + }); + + expect(result).toEqual({ ...mockApiKeyWithEnvironments, actualKey: "generated_key" }); + expect(prisma.apiKey.create).toHaveBeenCalled(); + expect(apiKeyCache.revalidate).toHaveBeenCalled(); + }); + + it("throws DatabaseError on prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2002", + clientVersion: "0.0.1", + }); + + vi.mocked(prisma.apiKey.create).mockRejectedValueOnce(errToThrow); + + await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/lib/projects.test.ts b/apps/web/modules/organization/settings/api-keys/lib/projects.test.ts new file mode 100644 index 0000000000..52346cd6d4 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/lib/projects.test.ts @@ -0,0 +1,128 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TOrganizationProject } from "../types/api-keys"; +import { getProjectsByOrganizationId } from "./projects"; + +// Mock organization project data +const mockProjects: TOrganizationProject[] = [ + { + id: "project1", + name: "Project 1", + environments: [ + { + id: "env1", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + { + id: "env2", + type: "development", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project1", + appSetupCompleted: true, + }, + ], + }, + { + id: "project2", + name: "Project 2", + environments: [ + { + id: "env3", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project2", + appSetupCompleted: true, + }, + ], + }, +]; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/lib/project/cache", () => ({ + projectCache: { + tag: { + byOrganizationId: vi.fn(), + }, + }, +})); + +describe("Projects Management", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getProjectsByOrganizationId", () => { + it("retrieves projects by organization ID successfully", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects); + vi.mocked(projectCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + const result = await getProjectsByOrganizationId("org123"); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: "org123", + }, + select: { + id: true, + environments: true, + name: true, + }, + }); + }); + + it("returns empty array when no projects exist", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]); + vi.mocked(projectCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + const result = await getProjectsByOrganizationId("org123"); + + expect(result).toEqual([]); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: "org123", + }, + select: { + id: true, + environments: true, + name: true, + }, + }); + }); + + it("throws DatabaseError on prisma error", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: "P2002", + clientVersion: "0.0.1", + }); + vi.mocked(prisma.project.findMany).mockRejectedValueOnce(errToThrow); + vi.mocked(projectCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + await expect(getProjectsByOrganizationId("org123")).rejects.toThrow(DatabaseError); + }); + + it("bubbles up unexpected errors", async () => { + const unexpectedError = new Error("Unexpected error"); + vi.mocked(prisma.project.findMany).mockRejectedValueOnce(unexpectedError); + vi.mocked(projectCache.tag.byOrganizationId).mockReturnValue("org-tag"); + + await expect(getProjectsByOrganizationId("org123")).rejects.toThrow(unexpectedError); + }); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/lib/projects.ts b/apps/web/modules/organization/settings/api-keys/lib/projects.ts new file mode 100644 index 0000000000..655bdda3cf --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/lib/projects.ts @@ -0,0 +1,39 @@ +import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { cache } from "@formbricks/lib/cache"; +import { projectCache } from "@formbricks/lib/project/cache"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const getProjectsByOrganizationId = reactCache( + async (organizationId: string): Promise => + cache( + async () => { + try { + const projects = await prisma.project.findMany({ + where: { + organizationId, + }, + select: { + id: true, + environments: true, + name: true, + }, + }); + + return projects; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getProjectsByOrganizationId-${organizationId}`], + { + tags: [projectCache.tag.byOrganizationId(organizationId)], + } + )() +); diff --git a/apps/web/modules/organization/settings/api-keys/lib/utils.ts b/apps/web/modules/organization/settings/api-keys/lib/utils.ts new file mode 100644 index 0000000000..489bfa9093 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/lib/utils.ts @@ -0,0 +1,51 @@ +import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; + +// Permission level required for different HTTP methods +const methodPermissionMap = { + GET: "read", // Read operations need at least read permission + POST: "write", // Create operations need at least write permission + PUT: "write", // Update operations need at least write permission + PATCH: "write", // Partial update operations need at least write permission + DELETE: "manage", // Delete operations need manage permission +}; + +// Check if API key has sufficient permission for the requested environment and method +export const hasPermission = ( + permissions: TAPIKeyEnvironmentPermission[], + environmentId: string, + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" +): boolean => { + if (!permissions) return false; + + // Find the environment permission entry for this environment + const environmentPermission = permissions.find((permission) => permission.environmentId === environmentId); + + if (!environmentPermission) return false; + + // Get required permission level for this method + const requiredPermission = methodPermissionMap[method]; + + // Check if the API key has sufficient permission + switch (environmentPermission.permission) { + case "manage": + // Manage permission can do everything + return true; + case "write": + // Write permission can do write and read operations + return requiredPermission === "write" || requiredPermission === "read"; + case "read": + // Read permission can only do read operations + return requiredPermission === "read"; + default: + return false; + } +}; + +export const getOrganizationAccessKeyDisplayName = (key: string) => { + switch (key) { + case "accessControl": + return "environments.project.api_keys.access_control"; + default: + return key; + } +}; diff --git a/apps/web/modules/projects/settings/api-keys/loading.tsx b/apps/web/modules/organization/settings/api-keys/loading.tsx similarity index 80% rename from apps/web/modules/projects/settings/api-keys/loading.tsx rename to apps/web/modules/organization/settings/api-keys/loading.tsx index 1ffa986a7d..0d2bb18169 100644 --- a/apps/web/modules/projects/settings/api-keys/loading.tsx +++ b/apps/web/modules/organization/settings/api-keys/loading.tsx @@ -1,6 +1,6 @@ "use client"; -import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation"; +import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { useTranslate } from "@tolgee/react"; @@ -19,7 +19,7 @@ const LoadingCard = () => {
{t("common.label")}
- {t("environments.project.api-keys.api_key")} + {t("environments.project.api_keys.api_key")}
{t("common.created_at")}
@@ -38,15 +38,17 @@ const LoadingCard = () => { ); }; -export const APIKeysLoading = () => { +const Loading = ({ isFormbricksCloud }: { isFormbricksCloud: boolean }) => { const { t } = useTranslate(); return ( - - + +
); }; + +export default Loading; diff --git a/apps/web/modules/organization/settings/api-keys/page.tsx b/apps/web/modules/organization/settings/api-keys/page.tsx new file mode 100644 index 0000000000..ddcfae4a89 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/page.tsx @@ -0,0 +1,52 @@ +import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects"; +import { Alert } from "@/modules/ui/components/alert"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { getTranslate } from "@/tolgee/server"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +import { ApiKeyList } from "./components/api-key-list"; + +export const APIKeysPage = async (props) => { + const params = await props.params; + const t = await getTranslate(); + const locale = await findMatchingLocale(); + + const { currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId); + + const projects = await getProjectsByOrganizationId(organization.id); + + const isReadOnly = currentUserMembership.role !== "owner" && currentUserMembership.role !== "manager"; + + return ( + + + + + {isReadOnly ? ( + + {t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")} + + ) : ( + + + + )} + + ); +}; diff --git a/apps/web/modules/organization/settings/api-keys/types/api-keys.ts b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts new file mode 100644 index 0000000000..7fa5a986c6 --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts @@ -0,0 +1,47 @@ +import { ApiKey, ApiKeyPermission } from "@prisma/client"; +import { z } from "zod"; +import { ZApiKey } from "@formbricks/database/zod/api-keys"; +import { ZOrganizationAccess } from "@formbricks/types/api-key"; +import { ZEnvironment } from "@formbricks/types/environment"; + +export const ZApiKeyEnvironmentPermission = z.object({ + environmentId: z.string(), + permission: z.nativeEnum(ApiKeyPermission), +}); + +export const ZApiKeyCreateInput = ZApiKey.required({ + label: true, +}) + .pick({ + label: true, + }) + .extend({ + environmentPermissions: z.array(ZApiKeyEnvironmentPermission).optional(), + organizationAccess: ZOrganizationAccess, + }); + +export type TApiKeyCreateInput = z.infer; + +export interface TApiKey extends ApiKey { + apiKey?: string; +} + +export const OrganizationProject = z.object({ + id: z.string(), + name: z.string(), + environments: z.array(ZEnvironment), +}); + +export type TOrganizationProject = z.infer; + +export const TApiKeyEnvironmentPermission = z.object({ + environmentId: z.string(), + permission: z.nativeEnum(ApiKeyPermission), +}); + +export type TApiKeyEnvironmentPermission = z.infer; + +export interface TApiKeyWithEnvironmentPermission + extends Pick { + apiKeyEnvironments: TApiKeyEnvironmentPermission[]; +} diff --git a/apps/web/modules/projects/settings/api-keys/components/add-api-key-modal.tsx b/apps/web/modules/projects/settings/api-keys/components/add-api-key-modal.tsx deleted file mode 100644 index 3d44191164..0000000000 --- a/apps/web/modules/projects/settings/api-keys/components/add-api-key-modal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { Button } from "@/modules/ui/components/button"; -import { Input } from "@/modules/ui/components/input"; -import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; -import { useTranslate } from "@tolgee/react"; -import { AlertTriangleIcon } from "lucide-react"; -import { useForm } from "react-hook-form"; - -interface MemberModalProps { - open: boolean; - setOpen: (v: boolean) => void; - onSubmit: (data: { label: string; environment: string }) => void; -} - -export const AddApiKeyModal = ({ open, setOpen, onSubmit }: MemberModalProps) => { - const { t } = useTranslate(); - const { register, getValues, handleSubmit, reset } = useForm<{ label: string; environment: string }>(); - - const submitAPIKey = async () => { - const data = getValues(); - onSubmit(data); - setOpen(false); - reset(); - }; - - return ( - -
-
-
-
-
- {t("environments.project.api-keys.add_api_key")} -
-
-
-
-
-
-
-
- - value.trim() !== "" })} - /> -
- -
- -

{t("environments.project.api-keys.api_key_security_warning")}

-
-
-
-
-
- - -
-
-
-
-
- ); -}; diff --git a/apps/web/modules/projects/settings/api-keys/components/api-key-list.tsx b/apps/web/modules/projects/settings/api-keys/components/api-key-list.tsx deleted file mode 100644 index ab62375135..0000000000 --- a/apps/web/modules/projects/settings/api-keys/components/api-key-list.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { getApiKeys } from "@/modules/projects/settings/api-keys/lib/api-key"; -import { getTranslate } from "@/tolgee/server"; -import { getEnvironments } from "@formbricks/lib/environment/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { TUserLocale } from "@formbricks/types/user"; -import { EditAPIKeys } from "./edit-api-keys"; - -interface ApiKeyListProps { - environmentId: string; - environmentType: string; - locale: TUserLocale; - isReadOnly: boolean; -} - -export const ApiKeyList = async ({ environmentId, environmentType, locale, isReadOnly }: ApiKeyListProps) => { - const t = await getTranslate(); - const findEnvironmentByType = (environments, targetType) => { - for (const environment of environments) { - if (environment.type === targetType) { - return environment.id; - } - } - return null; - }; - - const project = await getProjectByEnvironmentId(environmentId); - if (!project) { - throw new Error(t("common.project_not_found")); - } - - const environments = await getEnvironments(project.id); - const environmentTypeId = findEnvironmentByType(environments, environmentType); - const apiKeys = await getApiKeys(environmentTypeId); - - return ( - - ); -}; diff --git a/apps/web/modules/projects/settings/api-keys/components/edit-api-keys.tsx b/apps/web/modules/projects/settings/api-keys/components/edit-api-keys.tsx deleted file mode 100644 index 31479d4d0f..0000000000 --- a/apps/web/modules/projects/settings/api-keys/components/edit-api-keys.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys"; -import { Button } from "@/modules/ui/components/button"; -import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; -import { useTranslate } from "@tolgee/react"; -import { FilesIcon, TrashIcon } from "lucide-react"; -import { useState } from "react"; -import toast from "react-hot-toast"; -import { timeSince } from "@formbricks/lib/time"; -import { TUserLocale } from "@formbricks/types/user"; -import { createApiKeyAction, deleteApiKeyAction } from "../actions"; -import { AddApiKeyModal } from "./add-api-key-modal"; - -interface EditAPIKeysProps { - environmentTypeId: string; - environmentType: string; - apiKeys: TApiKey[]; - environmentId: string; - locale: TUserLocale; - isReadOnly: boolean; -} - -export const EditAPIKeys = ({ - environmentTypeId, - environmentType, - apiKeys, - environmentId, - locale, - isReadOnly, -}: EditAPIKeysProps) => { - const { t } = useTranslate(); - const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false); - const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false); - const [apiKeysLocal, setApiKeysLocal] = useState(apiKeys); - const [activeKey, setActiveKey] = useState({} as any); - - const handleOpenDeleteKeyModal = (e, apiKey) => { - e.preventDefault(); - setActiveKey(apiKey); - setOpenDeleteKeyModal(true); - }; - - const handleDeleteKey = async () => { - try { - await deleteApiKeyAction({ id: activeKey.id }); - const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || []; - setApiKeysLocal(updatedApiKeys); - toast.success(t("environments.project.api-keys.api_key_deleted")); - } catch (e) { - toast.error(t("environments.project.api-keys.unable_to_delete_api_key")); - } finally { - setOpenDeleteKeyModal(false); - } - }; - - const handleAddAPIKey = async (data) => { - const createApiKeyResponse = await createApiKeyAction({ - environmentId: environmentTypeId, - apiKeyData: { label: data.label }, - }); - if (createApiKeyResponse?.data) { - const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data]; - setApiKeysLocal(updatedApiKeys); - toast.success(t("environments.project.api-keys.api_key_created")); - } else { - const errorMessage = getFormattedErrorMessage(createApiKeyResponse); - toast.error(errorMessage); - } - - setOpenAddAPIKeyModal(false); - }; - - const ApiKeyDisplay = ({ apiKey }) => { - const copyToClipboard = () => { - navigator.clipboard.writeText(apiKey); - toast.success(t("environments.project.api-keys.api_key_copied_to_clipboard")); - }; - - if (!apiKey) { - return {t("environments.project.api-keys.secret")}; - } - - return ( -
- {apiKey} -
- -
-
- ); - }; - - return ( -
-
-
-
{t("common.label")}
-
- {t("environments.project.api-keys.api_key")} -
-
{t("common.created_at")}
-
-
-
- {apiKeysLocal && apiKeysLocal.length === 0 ? ( -
- {t("environments.project.api-keys.no_api_keys_yet")} -
- ) : ( - apiKeysLocal && - apiKeysLocal.map((apiKey) => ( -
-
{apiKey.label}
-
- -
-
- {timeSince(apiKey.createdAt.toString(), locale)} -
- {!isReadOnly && ( -
- -
- )} -
- )) - )} -
-
- - {!isReadOnly && ( -
- -
- )} - - -
- ); -}; diff --git a/apps/web/modules/projects/settings/api-keys/lib/api-key.ts b/apps/web/modules/projects/settings/api-keys/lib/api-key.ts deleted file mode 100644 index d341a94d21..0000000000 --- a/apps/web/modules/projects/settings/api-keys/lib/api-key.ts +++ /dev/null @@ -1,103 +0,0 @@ -import "server-only"; -import { apiKeyCache } from "@/lib/cache/api-key"; -import { TApiKeyCreateInput, ZApiKeyCreateInput } from "@/modules/projects/settings/api-keys/types/api-keys"; -import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys"; -import { ApiKey, Prisma } from "@prisma/client"; -import { createHash, randomBytes } from "crypto"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { ITEMS_PER_PAGE } from "@formbricks/lib/constants"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { ZId, ZOptionalNumber } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; - -export const getApiKeys = reactCache( - async (environmentId: string, page?: number): Promise => - cache( - async () => { - validateInputs([environmentId, ZId], [page, ZOptionalNumber]); - - try { - const apiKeys = await prisma.apiKey.findMany({ - where: { - environmentId, - }, - take: page ? ITEMS_PER_PAGE : undefined, - skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, - }); - - return apiKeys; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`getApiKeys-${environmentId}-${page}`], - { - tags: [apiKeyCache.tag.byEnvironmentId(environmentId)], - } - )() -); - -export const deleteApiKey = async (id: string): Promise => { - validateInputs([id, ZId]); - - try { - const deletedApiKeyData = await prisma.apiKey.delete({ - where: { - id: id, - }, - }); - - apiKeyCache.revalidate({ - id: deletedApiKeyData.id, - hashedKey: deletedApiKeyData.hashedKey, - environmentId: deletedApiKeyData.environmentId, - }); - - return deletedApiKeyData; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - -const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); - -export const createApiKey = async ( - environmentId: string, - apiKeyData: TApiKeyCreateInput -): Promise => { - validateInputs([environmentId, ZId], [apiKeyData, ZApiKeyCreateInput]); - try { - const key = randomBytes(16).toString("hex"); - const hashedKey = hashApiKey(key); - - const result = await prisma.apiKey.create({ - data: { - ...apiKeyData, - hashedKey, - environment: { connect: { id: environmentId } }, - }, - }); - - apiKeyCache.revalidate({ - id: result.id, - hashedKey: result.hashedKey, - environmentId: result.environmentId, - }); - - return { ...result, apiKey: key }; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; diff --git a/apps/web/modules/projects/settings/api-keys/page.tsx b/apps/web/modules/projects/settings/api-keys/page.tsx deleted file mode 100644 index a3bd57ab75..0000000000 --- a/apps/web/modules/projects/settings/api-keys/page.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; -import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; -import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation"; -import { EnvironmentNotice } from "@/modules/ui/components/environment-notice"; -import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { getTranslate } from "@/tolgee/server"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; -import { ApiKeyList } from "./components/api-key-list"; - -export const APIKeysPage = async (props) => { - const params = await props.params; - const t = await getTranslate(); - - // Use the new utility to get all required data with authorization checks - const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId); - - const locale = await findMatchingLocale(); - - return ( - - - - - - {environment.type === "development" ? ( - - - - ) : ( - - - - )} - - ); -}; diff --git a/apps/web/modules/projects/settings/api-keys/types/api-keys.ts b/apps/web/modules/projects/settings/api-keys/types/api-keys.ts deleted file mode 100644 index eb430093f0..0000000000 --- a/apps/web/modules/projects/settings/api-keys/types/api-keys.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApiKey } from "@prisma/client"; -import { z } from "zod"; -import { ZApiKey } from "@formbricks/database/zod/api-keys"; - -export const ZApiKeyCreateInput = ZApiKey.required({ - label: true, -}).pick({ - label: true, -}); - -export type TApiKeyCreateInput = z.infer; - -export interface TApiKey extends ApiKey { - apiKey?: string; -} diff --git a/apps/web/modules/projects/settings/components/project-config-navigation.tsx b/apps/web/modules/projects/settings/components/project-config-navigation.tsx index a173c1b7c2..9ffac70618 100644 --- a/apps/web/modules/projects/settings/components/project-config-navigation.tsx +++ b/apps/web/modules/projects/settings/components/project-config-navigation.tsx @@ -2,7 +2,7 @@ import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { useTranslate } from "@tolgee/react"; -import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react"; +import { BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react"; import { usePathname } from "next/navigation"; interface ProjectConfigNavigationProps { @@ -48,13 +48,6 @@ export const ProjectConfigNavigation = ({ href: `/environments/${environmentId}/project/tags`, current: pathname?.includes("/tags"), }, - { - id: "api-keys", - label: t("common.api_keys"), - icon: , - href: `/environments/${environmentId}/project/api-keys`, - current: pathname?.includes("/api-keys"), - }, { id: "app-connection", label: t("common.website_and_app_connection"), diff --git a/apps/web/package.json b/apps/web/package.json index 1e10f819cb..6d250ecc50 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -155,6 +155,7 @@ "@types/qrcode": "1.5.5", "@types/testing-library__react": "10.2.0", "@vitest/coverage-v8": "2.1.8", + "resize-observer-polyfill": "1.5.1", "vite": "6.2.3", "vite-tsconfig-paths": "5.1.4", "vitest": "3.0.7", diff --git a/apps/web/playwright/lib/utils.ts b/apps/web/playwright/lib/utils.ts index fe8999b413..66b33c66a6 100644 --- a/apps/web/playwright/lib/utils.ts +++ b/apps/web/playwright/lib/utils.ts @@ -13,11 +13,15 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) { throw new Error("Unable to parse environmentId from URL"); })(); - await page.goto(`/environments/${environmentId}/project/api-keys`); + await page.goto(`/environments/${environmentId}/settings/api-keys`); - await page.getByRole("button", { name: "Add Production API Key" }).isVisible(); - await page.getByRole("button", { name: "Add Production API Key" }).click(); + await page.getByRole("button", { name: "Add API Key" }).isVisible(); + await page.getByRole("button", { name: "Add API Key" }).click(); await page.getByPlaceholder("e.g. GitHub, PostHog, Slack").fill("E2E Test API Key"); + await page.getByRole("button", { name: "development" }).click(); + await page.getByRole("menuitem", { name: "production" }).click(); + await page.getByRole("button", { name: "read" }).click(); + await page.getByRole("menuitem", { name: "manage" }).click(); await page.getByRole("button", { name: "Add API Key" }).click(); await page.locator(".copyApiKeyIcon").click(); diff --git a/apps/web/playwright/organization.spec.ts b/apps/web/playwright/organization.spec.ts index 5b839afd38..931022b186 100644 --- a/apps/web/playwright/organization.spec.ts +++ b/apps/web/playwright/organization.spec.ts @@ -24,7 +24,7 @@ test.describe("Invite, accept and remove organization member", async () => { await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" }); - await page.getByRole("link", { name: "Teams" }).click(); + await page.getByRole("link", { name: "Access Control" }).click(); // Add member button await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible(); @@ -140,8 +140,8 @@ test.describe("Create, update and delete team", async () => { await page.waitForTimeout(2000); await page.waitForLoadState("networkidle"); - await expect(page.getByText("Teams")).toBeVisible(); - await page.getByText("Teams").click(); + await expect(page.getByText("Access Control")).toBeVisible(); + await page.getByText("Access Control").click(); await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/); await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible(); await page.getByRole("button", { name: "Create new team" }).click(); diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 8800bff259..aa6fe1497d 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -43,6 +43,10 @@ export default defineConfig({ "app/api/(internal)/insights/lib/**/*.ts", "modules/ee/role-management/*.ts", "modules/organization/settings/teams/actions.ts", + "modules/organization/settings/api-keys/lib/**/*.ts", + "app/api/v1/**/*.ts", + "modules/api/v2/management/auth/*.ts", + "modules/organization/settings/api-keys/components/*.tsx", "modules/survey/hooks/*.tsx", "modules/survey/lib/client-utils.ts", "modules/survey/list/components/survey-card.tsx", diff --git a/package.json b/package.json index 21eacdb4db..cb395a3d51 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,7 @@ }, "lint-staged": { "(apps|packages)/**/*.{js,ts,jsx,tsx}": [ - "prettier --write", - "eslint --fix" + "prettier --write" ], "*.json": [ "prettier --write" diff --git a/packages/database/json-types.ts b/packages/database/json-types.ts index 6b33826c1f..dd02168d1b 100644 --- a/packages/database/json-types.ts +++ b/packages/database/json-types.ts @@ -1,6 +1,7 @@ /* eslint-disable import/no-relative-packages -- required for importing types */ /* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */ import { type TActionClassNoCodeConfig } from "../types/action-classes"; +import type { TOrganizationAccess } from "../types/api-key"; import { type TIntegrationConfig } from "../types/integration"; import { type TOrganizationBilling } from "../types/organizations"; import { type TProjectConfig, type TProjectStyling } from "../types/project"; @@ -45,5 +46,6 @@ declare global { export type Locale = TUserLocale; export type SurveyFollowUpTrigger = TSurveyFollowUpTrigger; export type SurveyFollowUpAction = TSurveyFollowUpAction; + export type OrganizationAccess = TOrganizationAccess; } } diff --git a/packages/database/migration/20250326083401_add_api_keys_to_organization/migration.sql b/packages/database/migration/20250326083401_add_api_keys_to_organization/migration.sql new file mode 100644 index 0000000000..c342fa820c --- /dev/null +++ b/packages/database/migration/20250326083401_add_api_keys_to_organization/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "ApiKeyPermission" AS ENUM ('read', 'write', 'manage'); + +-- CreateTable +CREATE TABLE "ApiKeyNew" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT, + "lastUsedAt" TIMESTAMP(3), + "label" TEXT NOT NULL, + "hashedKey" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "ApiKeyNew_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ApiKeyEnvironment" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "apiKeyId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "permission" "ApiKeyPermission" NOT NULL, + + CONSTRAINT "ApiKeyEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKeyNew_hashedKey_key" ON "ApiKeyNew"("hashedKey"); + +-- CreateIndex +CREATE INDEX "ApiKeyNew_organizationId_idx" ON "ApiKeyNew"("organizationId"); + +-- CreateIndex +CREATE INDEX "ApiKeyEnvironment_environmentId_idx" ON "ApiKeyEnvironment"("environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKeyEnvironment_apiKeyId_environmentId_key" ON "ApiKeyEnvironment"("apiKeyId", "environmentId"); + +-- AddForeignKey +ALTER TABLE "ApiKeyNew" ADD CONSTRAINT "ApiKeyNew_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKeyNew"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/migration/20250326111101_move_api_keys_to_api_keys_new/migration.ts b/packages/database/migration/20250326111101_move_api_keys_to_api_keys_new/migration.ts new file mode 100644 index 0000000000..c8005b23cc --- /dev/null +++ b/packages/database/migration/20250326111101_move_api_keys_to_api_keys_new/migration.ts @@ -0,0 +1,83 @@ +import type { MigrationScript } from "../../src/scripts/migration-runner"; + +export const moveApiKeysToApiKeysNew: MigrationScript = { + type: "data", + id: "mvwdryxrxaf8rhr97g2zlv3m", + name: "20250326111101_move_api_keys_to_api_keys_new", + run: async ({ tx }) => { + // Step 1: Get all existing API keys with related data + const apiKeys = await tx.$queryRaw` + SELECT + ak.*, + e.id as "environmentId", + p.id as "projectId", + o.id as "organizationId" + FROM "ApiKey" ak + JOIN "Environment" e ON ak."environmentId" = e.id + JOIN "Project" p ON e."projectId" = p.id + JOIN "Organization" o ON p."organizationId" = o.id + `; + // @ts-expect-error + console.log(`Found ${apiKeys.length} API keys to migrate.`); + let migratedCount = 0; + // Step 2: Migrate each API key to the new format + // @ts-expect-error + for (const apiKey of apiKeys) { + const organizationId = apiKey.organizationId; + + try { + // Check if the API key already exists in the new table + const existingKey = await tx.$queryRaw` + SELECT id FROM "ApiKeyNew" WHERE id = ${apiKey.id} + `; + + if (Array.isArray(existingKey) && existingKey.length > 0) { + continue; + } + + // Check if the API key environment relation already exists + const existingEnv = await tx.$queryRaw` + SELECT id FROM "ApiKeyEnvironment" + WHERE "apiKeyId" = ${apiKey.id} AND "environmentId" = ${apiKey.environmentId} + `; + + if (Array.isArray(existingEnv) && existingEnv.length > 0) { + continue; + } + + // Step 3: Create new API key in the ApiKeyNew table and its environment relation + await tx.$executeRaw` + INSERT INTO "ApiKeyNew" ( + "id", + "createdAt", + "lastUsedAt", + "label", + "hashedKey", + "organizationId" + ) VALUES ( + ${apiKey.id}, + ${apiKey.createdAt}, + ${apiKey.lastUsedAt}, + ${apiKey.label}, + ${apiKey.hashedKey}, + ${organizationId} + ) + `; + + // Create the API key environment relation using Prisma + await tx.apiKeyEnvironment.create({ + data: { + apiKeyId: apiKey.id, + environmentId: apiKey.environmentId, + permission: "manage", + }, + }); + migratedCount++; + } catch (error) { + console.error(`Error migrating API key ${apiKey.id}:`, error); + } + } + + console.log(`API key migration completed. Migrated ${migratedCount} API keys.`); + }, +}; diff --git a/packages/database/migration/20250327043931_api_key_new_to_api_key/migration.sql b/packages/database/migration/20250327043931_api_key_new_to_api_key/migration.sql new file mode 100644 index 0000000000..74f035d2e2 --- /dev/null +++ b/packages/database/migration/20250327043931_api_key_new_to_api_key/migration.sql @@ -0,0 +1,30 @@ +BEGIN; + -- Lock both tables to prevent any modifications during migration + LOCK TABLE "ApiKey" IN ACCESS EXCLUSIVE MODE; + LOCK TABLE "ApiKeyNew" IN ACCESS EXCLUSIVE MODE; + + -- Verify all data is migrated before proceeding + DO $$ + BEGIN + IF (SELECT COUNT(*) FROM "ApiKey") != (SELECT COUNT(*) FROM "ApiKeyNew") THEN + RAISE EXCEPTION 'Data migration incomplete. Counts do not match.'; + END IF; + END $$; + + -- Drop the old ApiKey table first + DROP TABLE IF EXISTS "ApiKey"; + + -- Rename ApiKeyNew to ApiKey + ALTER TABLE "ApiKeyNew" RENAME TO "ApiKey"; + ALTER TABLE "ApiKey" RENAME CONSTRAINT "ApiKeyNew_pkey" TO "ApiKey_pkey"; + ALTER INDEX "ApiKeyNew_hashedKey_key" RENAME TO "ApiKey_hashedKey_key"; + ALTER INDEX "ApiKeyNew_organizationId_idx" RENAME TO "ApiKey_organizationId_idx"; + + -- Update the constraints to maintain foreign key relationships + ALTER TABLE "ApiKeyEnvironment" DROP CONSTRAINT "ApiKeyEnvironment_apiKeyId_fkey"; + ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + -- Rename the foreign key constraint + ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKeyNew_organizationId_fkey"; + ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +COMMIT; \ No newline at end of file diff --git a/packages/database/migration/20250402084646_add_organization_access_to_api_key/migration.sql b/packages/database/migration/20250402084646_add_organization_access_to_api_key/migration.sql new file mode 100644 index 0000000000..4fbb382986 --- /dev/null +++ b/packages/database/migration/20250402084646_add_organization_access_to_api_key/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "organizationAccess" JSONB NOT NULL DEFAULT '{}'; diff --git a/packages/database/migration/20250402084801_set_default_organization_access_to_all_existing_api_keys/migration.ts b/packages/database/migration/20250402084801_set_default_organization_access_to_all_existing_api_keys/migration.ts new file mode 100644 index 0000000000..1e50ccfbc1 --- /dev/null +++ b/packages/database/migration/20250402084801_set_default_organization_access_to_all_existing_api_keys/migration.ts @@ -0,0 +1,18 @@ +import type { MigrationScript } from "../../src/scripts/migration-runner"; + +export const setDefaultOrganizationAccessToAllExistingApiKeys: MigrationScript = { + type: "data", + id: "jd54tyjvat97yn9rgkgsneaq", + name: "20250402084801_set_default_organization_access_to_all_existing_api_keys", + run: async ({ tx }) => { + try { + await tx.$queryRaw` + UPDATE "ApiKey" + SET "organizationAccess" = '{"accessControl":{"read":false,"write":false}}' + WHERE "organizationAccess" IS NULL OR "organizationAccess" = '{}' + `; + } catch (error) { + console.error("Error adding organization access to API keys", error); + } + }, +}; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index d0992cb188..b7bf760a77 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -555,13 +555,13 @@ model Environment { contacts Contact[] actionClasses ActionClass[] attributeKeys ContactAttributeKey[] - apiKeys ApiKey[] webhooks Webhook[] tags Tag[] segments Segment[] integration Integration[] documents Document[] insights Insight[] + ApiKeyEnvironment ApiKeyEnvironment[] @@index([projectId]) } @@ -639,6 +639,7 @@ model Organization { invites Invite[] isAIEnabled Boolean @default(false) teams Team[] + apiKeys ApiKey[] } enum OrganizationRole { @@ -711,23 +712,58 @@ model Invite { @@index([organizationId]) } -/// Represents API authentication keys. -/// Used for authenticating API requests to Formbricks. +/// Represents enhanced API authentication keys with organization-level ownership. +/// Used for authenticating API requests to Formbricks with more granular permissions. /// /// @property id - Unique identifier for the API key /// @property label - Optional descriptive name for the key /// @property hashedKey - Securely stored API key -/// @property environment - The environment this key belongs to +/// @property organization - The organization this key belongs to +/// @property createdBy - User ID who created this key /// @property lastUsedAt - Timestamp of last usage +/// @property apiKeyEnvironments - Environments this key has access to model ApiKey { - id String @id @unique @default(cuid()) - createdAt DateTime @default(now()) - lastUsedAt DateTime? - label String? - hashedKey String @unique() - environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) - environmentId String + id String @id @default(cuid()) + createdAt DateTime @default(now()) + createdBy String? + lastUsedAt DateTime? + label String + hashedKey String @unique + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + apiKeyEnvironments ApiKeyEnvironment[] + /// [OrganizationAccess] + organizationAccess Json @default("{}") + @@index([organizationId]) +} + +/// Defines permission levels for API keys. +/// Controls what operations an API key can perform. +enum ApiKeyPermission { + read + write + manage +} + +/// Links API keys to environments with specific permissions. +/// Enables granular access control for API keys across environments. +/// +/// @property id - Unique identifier for the environment access entry +/// @property apiKey - The associated API key +/// @property environment - The environment being accessed +/// @property permission - Level of access granted +model ApiKeyEnvironment { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + apiKeyId String + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + permission ApiKeyPermission + + @@unique([apiKeyId, environmentId]) @@index([environmentId]) } diff --git a/packages/database/src/scripts/migration-runner.ts b/packages/database/src/scripts/migration-runner.ts index 2f43c4b986..458798af65 100644 --- a/packages/database/src/scripts/migration-runner.ts +++ b/packages/database/src/scripts/migration-runner.ts @@ -1,8 +1,8 @@ +import { type Prisma, PrismaClient } from "@prisma/client"; import { exec } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { type Prisma, PrismaClient } from "@prisma/client"; import { logger } from "@formbricks/logger"; const execAsync = promisify(exec); @@ -44,6 +44,7 @@ const runMigrations = async (migrations: MigrationScript[]): Promise => { const runSingleMigration = async (migration: MigrationScript, index: number): Promise => { if (migration.type === "data") { + let hasLock = false; logger.info(`Running data migration: ${migration.name}`); try { @@ -76,6 +77,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr } else { // create a new data migration entry with pending status await prisma.$executeRaw`INSERT INTO "DataMigration" (id, name, status) VALUES (${migration.id}, ${migration.name}, 'pending')`; + hasLock = true; } if (migration.run) { @@ -100,12 +102,15 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr } catch (error) { // Record migration failure logger.error(error, `Data migration ${migration.name} failed`); - // Mark migration as failed - await prisma.$queryRaw` - INSERT INTO "DataMigration" (id, name, status) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- we need to check if the migration has a lock + if (hasLock) { + // Mark migration as failed + await prisma.$queryRaw` + INSERT INTO "DataMigration" (id, name, status) VALUES (${migration.id}, ${migration.name}, 'failed') - ON CONFLICT (id) DO UPDATE SET status = 'failed'; - `; + ON CONFLICT (id) DO UPDATE SET status = 'failed'; + `; + } throw error; } diff --git a/packages/database/zod/api-keys.ts b/packages/database/zod/api-keys.ts index f63134973c..db15dedde0 100644 --- a/packages/database/zod/api-keys.ts +++ b/packages/database/zod/api-keys.ts @@ -1,11 +1,39 @@ -import { type ApiKey } from "@prisma/client"; +import { type ApiKey, type ApiKeyEnvironment, ApiKeyPermission } from "@prisma/client"; import { z } from "zod"; +import { ZOrganizationAccess } from "../../types/api-key"; + +export const ZApiKeyPermission = z.nativeEnum(ApiKeyPermission); + +export const ZApiKeyEnvironment = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + updatedAt: z.date(), + apiKeyId: z.string().cuid2(), + environmentId: z.string().cuid2(), + permission: ZApiKeyPermission, +}) satisfies z.ZodType; export const ZApiKey = z.object({ id: z.string().cuid2(), createdAt: z.date(), + createdBy: z.string(), lastUsedAt: z.date().nullable(), - label: z.string().nullable(), + label: z.string(), hashedKey: z.string(), - environmentId: z.string().cuid2(), + organizationId: z.string().cuid2(), + organizationAccess: ZOrganizationAccess, }) satisfies z.ZodType; + +export const ZApiKeyCreateInput = z.object({ + label: z.string(), + organizationId: z.string().cuid2(), + environmentIds: z.array(z.string().cuid2()), + permissions: z.record(z.string().cuid2(), ZApiKeyPermission), + createdBy: z.string(), +}); + +export const ZApiKeyEnvironmentCreateInput = z.object({ + apiKeyId: z.string().cuid2(), + environmentId: z.string().cuid2(), + permission: ZApiKeyPermission, +}); diff --git a/packages/js-core/src/lib/environment/state.ts b/packages/js-core/src/lib/environment/state.ts index d29c54d146..01aa0fbfb3 100644 --- a/packages/js-core/src/lib/environment/state.ts +++ b/packages/js-core/src/lib/environment/state.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console -- logging required for error logging */ -import { FormbricksAPI } from "@formbricks/api"; import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getIsDebug } from "@/lib/common/utils"; import type { TConfigInput, TEnvironmentState } from "@/types/config"; import { type ApiErrorResponse, type Result, err, ok } from "@/types/error"; +import { FormbricksAPI } from "@formbricks/api"; let environmentStateSyncIntervalId: number | null = null; diff --git a/packages/js-core/src/lib/environment/tests/state.test.ts b/packages/js-core/src/lib/environment/tests/state.test.ts index 82274a62f7..628c62850f 100644 --- a/packages/js-core/src/lib/environment/tests/state.test.ts +++ b/packages/js-core/src/lib/environment/tests/state.test.ts @@ -1,6 +1,4 @@ // state.test.ts -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { FormbricksAPI } from "@formbricks/api"; import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys } from "@/lib/common/utils"; @@ -10,6 +8,8 @@ import { fetchEnvironmentState, } from "@/lib/environment/state"; import type { TEnvironmentState } from "@/types/config"; +import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { FormbricksAPI } from "@formbricks/api"; // Mock the FormbricksAPI so we can control environment.getState vi.mock("@formbricks/api", () => ({ diff --git a/packages/js-core/src/lib/survey/tests/action.test.ts b/packages/js-core/src/lib/survey/tests/action.test.ts index 59f6bb2c60..f2c7fcb203 100644 --- a/packages/js-core/src/lib/survey/tests/action.test.ts +++ b/packages/js-core/src/lib/survey/tests/action.test.ts @@ -1,9 +1,9 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { trackAction, trackCodeAction, trackNoCodeAction } from "@/lib/survey/action"; import { SurveyStore } from "@/lib/survey/store"; import { triggerSurvey } from "@/lib/survey/widget"; +import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@/lib/common/config", () => ({ Config: { diff --git a/packages/js-core/src/lib/survey/tests/widget.test.ts b/packages/js-core/src/lib/survey/tests/widget.test.ts index c7095b1188..ed070c4eb7 100644 --- a/packages/js-core/src/lib/survey/tests/widget.test.ts +++ b/packages/js-core/src/lib/survey/tests/widget.test.ts @@ -1,10 +1,10 @@ -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock"; import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils"; import * as widget from "@/lib/survey/widget"; import { type TEnvironmentStateSurvey } from "@/types/config"; +import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@/lib/common/config", () => ({ Config: { diff --git a/packages/js-core/src/lib/user/tests/update.test.ts b/packages/js-core/src/lib/user/tests/update.test.ts index 565492d025..91ba546b78 100644 --- a/packages/js-core/src/lib/user/tests/update.test.ts +++ b/packages/js-core/src/lib/user/tests/update.test.ts @@ -1,5 +1,3 @@ -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; -import { FormbricksAPI } from "@formbricks/api"; import { mockAppUrl, mockAttributes, @@ -10,6 +8,8 @@ import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { sendUpdates, sendUpdatesToBackend } from "@/lib/user/update"; import { type TUpdates } from "@/types/config"; +import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; +import { FormbricksAPI } from "@formbricks/api"; vi.mock("@/lib/common/config", () => ({ Config: { diff --git a/packages/js-core/src/lib/user/update.ts b/packages/js-core/src/lib/user/update.ts index ace4287a99..f3f7031ee7 100644 --- a/packages/js-core/src/lib/user/update.ts +++ b/packages/js-core/src/lib/user/update.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console -- required for logging errors */ -import { FormbricksAPI } from "@formbricks/api"; import { Config } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getIsDebug } from "@/lib/common/utils"; import { type TUpdates, type TUserState } from "@/types/config"; import { type ApiErrorResponse, type Result, type ResultError, err, ok, okVoid } from "@/types/error"; +import { FormbricksAPI } from "@formbricks/api"; export const sendUpdatesToBackend = async ({ appUrl, diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json index b89826998a..f4e62b6669 100644 --- a/packages/lib/messages/de-DE.json +++ b/packages/lib/messages/de-DE.json @@ -372,7 +372,7 @@ "team": "Team", "team_access": "Teamzugriff", "team_name": "Teamname", - "teams": "Teams", + "teams": "Zugriffskontrolle", "teams_not_found": "Teams nicht gefunden", "text": "Text", "time": "Zeit", @@ -792,6 +792,29 @@ "secret": "Geheimnis", "unable_to_delete_api_key": "API-Schlüssel kann nicht gelöscht werden" }, + "api_keys": { + "access_control": "Zugriffskontrolle", + "add_api_key": "API-Schlüssel hinzufügen", + "add_env_api_key": "{environmentType} API-Schlüssel hinzufügen", + "api_key": "API-Schlüssel", + "api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert", + "api_key_created": "API-Schlüssel erstellt", + "api_key_deleted": "API-Schlüssel gelöscht", + "api_key_label": "API-Schlüssel Label", + "api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.", + "dev_api_keys": "API-Schlüssel (Dev)", + "dev_api_keys_description": "API-Schlüssel für deine Entwicklungsumgebung hinzufügen und entfernen.", + "duplicate_access": "Doppelter Projektzugriff nicht erlaubt", + "duplicate_permissions": "Doppelte Berechtigungen sind nicht erlaubt", + "no_api_keys_yet": "Du hast noch keine API-Schlüssel", + "organization_access": "Organisationszugang", + "permissions": "Berechtigungen", + "prod_api_keys": "API-Schlüssel (Prod)", + "prod_api_keys_description": "API-Schlüssel für deine Produktionsumgebung hinzufügen und entfernen.", + "project_access": "Projektzugriff", + "secret": "Geheimnis", + "unable_to_delete_api_key": "API-Schlüssel kann nicht gelöscht werden" + }, "app-connection": { "api_host_description": "Dies ist die URL deines Formbricks Backends.", "app_connection": "App-Verbindung", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "mit dem Formbricks SDK" }, "settings": { + "api_keys": { + "add_api_key": "API-Schlüssel hinzufügen", + "add_permission": "Berechtigung hinzufügen", + "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen", + "only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten" + }, "billing": { "10000_monthly_responses": "10,000 monatliche Antworten", "1500_monthly_responses": "1,500 monatliche Antworten", diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json index 0ebab246cb..0cc257ff99 100644 --- a/packages/lib/messages/en-US.json +++ b/packages/lib/messages/en-US.json @@ -372,7 +372,7 @@ "team": "Team", "team_access": "Team Access", "team_name": "Team name", - "teams": "Teams", + "teams": "Access Control", "teams_not_found": "Teams not found", "text": "Text", "time": "Time", @@ -792,6 +792,29 @@ "secret": "Secret", "unable_to_delete_api_key": "Unable to delete API Key" }, + "api_keys": { + "access_control": "Access Control", + "add_api_key": "Add API Key", + "add_env_api_key": "Add {environmentType} API Key", + "api_key": "API Key", + "api_key_copied_to_clipboard": "API key copied to clipboard", + "api_key_created": "API key created", + "api_key_deleted": "API Key deleted", + "api_key_label": "API Key Label", + "api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.", + "dev_api_keys": "Development Env Keys", + "dev_api_keys_description": "Add and remove API keys for your Development environment.", + "duplicate_access": "Duplicate project access not allowed", + "duplicate_permissions": "Duplicate permissions not allowed", + "no_api_keys_yet": "You don't have any API keys yet", + "organization_access": "Organization Access", + "permissions": "Permissions", + "prod_api_keys": "Production Env Keys", + "prod_api_keys_description": "Add and remove API keys for your Production environment.", + "project_access": "Project Access", + "secret": "Secret", + "unable_to_delete_api_key": "Unable to delete API Key" + }, "app-connection": { "api_host_description": "This is the URL of your Formbricks backend.", "app_connection": "App Connection", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "with the Formbricks SDK" }, "settings": { + "api_keys": { + "add_api_key": "Add API key", + "add_permission": "Add permission", + "api_keys_description": "Manage API keys to access Formbricks management APIs", + "only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys" + }, "billing": { "10000_monthly_responses": "10000 Monthly Responses", "1500_monthly_responses": "1500 Monthly Responses", diff --git a/packages/lib/messages/fr-FR.json b/packages/lib/messages/fr-FR.json index 88750c2443..68578df2a2 100644 --- a/packages/lib/messages/fr-FR.json +++ b/packages/lib/messages/fr-FR.json @@ -372,7 +372,7 @@ "team": "Équipe", "team_access": "Accès Équipe", "team_name": "Nom de l'équipe", - "teams": "Équipes", + "teams": "Contrôle d'accès", "teams_not_found": "Équipes non trouvées", "text": "Texte", "time": "Temps", @@ -792,6 +792,29 @@ "secret": "Secret", "unable_to_delete_api_key": "Impossible de supprimer la clé API" }, + "api_keys": { + "access_control": "Contrôle d'accès", + "add_api_key": "Ajouter une clé API", + "add_env_api_key": "Ajouter la clé API {environmentType}", + "api_key": "Clé API", + "api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers", + "api_key_created": "Clé API créée", + "api_key_deleted": "Clé API supprimée", + "api_key_label": "Étiquette de clé API", + "api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.", + "dev_api_keys": "Clés de l'environnement de développement", + "dev_api_keys_description": "Ajoutez et supprimez des clés API pour votre environnement de développement.", + "duplicate_access": "L'accès en double au projet n'est pas autorisé", + "duplicate_permissions": "Les autorisations en double ne sont pas autorisées", + "no_api_keys_yet": "Vous n'avez pas encore de clés API.", + "organization_access": "Accès à l'organisation", + "permissions": "Permissions", + "prod_api_keys": "Clés de l'environnement de production", + "prod_api_keys_description": "Ajoutez et supprimez des clés API pour votre environnement de production.", + "project_access": "Accès au projet", + "secret": "Secret", + "unable_to_delete_api_key": "Impossible de supprimer la clé API" + }, "app-connection": { "api_host_description": "Ceci est l'URL de votre backend Formbricks.", "app_connection": "Connexion d'application", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "avec le SDK Formbricks" }, "settings": { + "api_keys": { + "add_api_key": "Ajouter une clé API", + "add_permission": "Ajouter une permission", + "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks", + "only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API" + }, "billing": { "10000_monthly_responses": "10000 Réponses Mensuelles", "1500_monthly_responses": "1500 Réponses Mensuelles", diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json index e9aa823ab2..80f02023e0 100644 --- a/packages/lib/messages/pt-BR.json +++ b/packages/lib/messages/pt-BR.json @@ -372,7 +372,7 @@ "team": "Time", "team_access": "Acesso da equipe", "team_name": "Nome da equipe", - "teams": "Times", + "teams": "Controle de Acesso", "teams_not_found": "Equipes não encontradas", "text": "Texto", "time": "tempo", @@ -792,6 +792,29 @@ "secret": "Segredo", "unable_to_delete_api_key": "Não foi possível deletar a Chave API" }, + "api_keys": { + "access_control": "Controle de Acesso", + "add_api_key": "Adicionar Chave API", + "add_env_api_key": "Adicionar chave de API {environmentType}", + "api_key": "Chave de API", + "api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência", + "api_key_created": "Chave da API criada", + "api_key_deleted": "Chave da API deletada", + "api_key_label": "Rótulo da Chave API", + "api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "dev_api_keys": "Chaves do Ambiente de Desenvolvimento", + "dev_api_keys_description": "Adicionar e remover chaves de API para o seu ambiente de Desenvolvimento.", + "duplicate_access": "Acesso duplicado ao projeto não permitido", + "duplicate_permissions": "Permissões duplicadas não são permitidas", + "no_api_keys_yet": "Você ainda não tem nenhuma chave de API", + "organization_access": "Acesso à Organização", + "permissions": "Permissões", + "prod_api_keys": "Chaves do Ambiente de Produção", + "prod_api_keys_description": "Adicionar e remover chaves de API para seu ambiente de Produção.", + "project_access": "Acesso ao Projeto", + "secret": "Segredo", + "unable_to_delete_api_key": "Não foi possível deletar a Chave API" + }, "app-connection": { "api_host_description": "Essa é a URL do seu backend do Formbricks.", "app_connection": "Conexão do App", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "com o SDK do Formbricks." }, "settings": { + "api_keys": { + "add_api_key": "Adicionar chave de API", + "add_permission": "Adicionar permissão", + "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks", + "only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API" + }, "billing": { "10000_monthly_responses": "10000 Respostas Mensais", "1500_monthly_responses": "1500 Respostas Mensais", diff --git a/packages/lib/messages/pt-PT.json b/packages/lib/messages/pt-PT.json index 98860b6f64..8c7a7c39be 100644 --- a/packages/lib/messages/pt-PT.json +++ b/packages/lib/messages/pt-PT.json @@ -372,7 +372,7 @@ "team": "Equipa", "team_access": "Acesso da Equipa", "team_name": "Nome da equipa", - "teams": "Equipas", + "teams": "Controlo de Acesso", "teams_not_found": "Equipas não encontradas", "text": "Texto", "time": "Tempo", @@ -792,6 +792,29 @@ "secret": "Segredo", "unable_to_delete_api_key": "Não é possível eliminar a chave API" }, + "api_keys": { + "access_control": "Controlo de Acesso", + "add_api_key": "Adicionar Chave API", + "add_env_api_key": "Adicionar Chave API {environmentType}", + "api_key": "Chave API", + "api_key_copied_to_clipboard": "Chave API copiada para a área de transferência", + "api_key_created": "Chave API criada", + "api_key_deleted": "Chave API eliminada", + "api_key_label": "Etiqueta da Chave API", + "api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "dev_api_keys": "Chaves de Ambiente de Desenvolvimento", + "dev_api_keys_description": "Adicionar e remover chaves de API para o seu ambiente de Desenvolvimento.", + "duplicate_access": "Acesso duplicado ao projeto não permitido", + "duplicate_permissions": "Permissões duplicadas não são permitidas", + "no_api_keys_yet": "Ainda não tem nenhuma chave API", + "organization_access": "Acesso à Organização", + "permissions": "Permissões", + "prod_api_keys": "Chaves de Ambiente de Produção", + "prod_api_keys_description": "Adicionar e remover chaves de API para o seu ambiente de Produção.", + "project_access": "Acesso ao Projeto", + "secret": "Segredo", + "unable_to_delete_api_key": "Não é possível eliminar a chave API" + }, "app-connection": { "api_host_description": "Este é o URL do seu backend Formbricks.", "app_connection": "Ligação de Aplicação", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "com o SDK Formbricks" }, "settings": { + "api_keys": { + "add_api_key": "Adicionar chave API", + "add_permission": "Adicionar permissão", + "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks", + "only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API" + }, "billing": { "10000_monthly_responses": "10000 Respostas Mensais", "1500_monthly_responses": "1500 Respostas Mensais", diff --git a/packages/lib/messages/zh-Hant-TW.json b/packages/lib/messages/zh-Hant-TW.json index 7a155c79a2..9526ca705e 100644 --- a/packages/lib/messages/zh-Hant-TW.json +++ b/packages/lib/messages/zh-Hant-TW.json @@ -372,7 +372,7 @@ "team": "團隊", "team_access": "團隊存取權限", "team_name": "團隊名稱", - "teams": "團隊", + "teams": "存取控制", "teams_not_found": "找不到團隊", "text": "文字", "time": "時間", @@ -792,6 +792,29 @@ "secret": "密碼", "unable_to_delete_api_key": "無法刪除 API 金鑰" }, + "api_keys": { + "access_control": "存取控制", + "add_api_key": "新增 API 金鑰", + "add_env_api_key": "新增 '{'environmentType'}' API 金鑰", + "api_key": "API 金鑰", + "api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿", + "api_key_created": "API 金鑰已建立", + "api_key_deleted": "API 金鑰已刪除", + "api_key_label": "API 金鑰標籤", + "api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。", + "dev_api_keys": "開發環境金鑰", + "dev_api_keys_description": "為您的開發環境新增和移除 API 金鑰。", + "duplicate_access": "不允許重複的 project 存取", + "duplicate_permissions": "不允許重複權限", + "no_api_keys_yet": "您還沒有任何 API 金鑰", + "organization_access": "組織 Access", + "permissions": "權限", + "prod_api_keys": "生產環境金鑰", + "prod_api_keys_description": "為您的生產環境新增和移除 API 金鑰。", + "project_access": "專案存取", + "secret": "密碼", + "unable_to_delete_api_key": "無法刪除 API 金鑰" + }, "app-connection": { "api_host_description": "這是您 Formbricks 後端的網址。", "app_connection": "應用程式連線", @@ -988,6 +1011,12 @@ "with_the_formbricks_sdk": "使用 Formbricks SDK" }, "settings": { + "api_keys": { + "add_api_key": "新增 API 金鑰", + "add_permission": "新增權限", + "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API", + "only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰" + }, "billing": { "10000_monthly_responses": "10000 個每月回應", "1500_monthly_responses": "1500 個每月回應", diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 19133f5d48..0bdbd40112 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -92,7 +92,7 @@ export const responseSelection = { }, } satisfies Prisma.ResponseSelect; -const getResponseContact = ( +export const getResponseContact = ( responsePrisma: Prisma.ResponseGetPayload<{ select: typeof responseSelection }> ): TResponseContact | null => { if (!responsePrisma.contact) return null; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index aea8f2ce76..593c4456b8 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -135,7 +135,7 @@ const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: Act } }; -const handleTriggerUpdates = ( +export const handleTriggerUpdates = ( updatedTriggers: TSurvey["triggers"], currentTriggers: TSurvey["triggers"], actionClasses: ActionClass[] diff --git a/packages/lib/vitestSetup.ts b/packages/lib/vitestSetup.ts index d4fefc99a3..1856498783 100644 --- a/packages/lib/vitestSetup.ts +++ b/packages/lib/vitestSetup.ts @@ -1,8 +1,15 @@ // mock these globally used functions import "@testing-library/jest-dom/vitest"; +import ResizeObserver from "resize-observer-polyfill"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { ValidationError } from "@formbricks/types/errors"; +// Make ResizeObserver available globally (Vitest/Jest environment) +// This is used by radix-ui +if (!global.ResizeObserver) { + global.ResizeObserver = ResizeObserver; +} + // mock react toast vi.mock("react-hot-toast", () => ({ diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 7fe6a4fdfa..8395007f45 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -1,13 +1,13 @@ /* eslint-disable no-console -- debugging*/ -import React, { type JSX, useEffect, useRef, useState } from "react"; -import { Modal } from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils"; import { SurveyStore } from "@/lib/survey/store"; import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config"; import type { SurveyContainerProps } from "@/types/survey"; +import React, { type JSX, useEffect, useRef, useState } from "react"; +import { Modal } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; const appConfig = RNConfig.getInstance(); const logger = Logger.getInstance(); diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts index cb5a4bf325..35d24d0f8a 100644 --- a/packages/react-native/src/lib/survey/action.ts +++ b/packages/react-native/src/lib/survey/action.ts @@ -1,10 +1,10 @@ -import { fetch } from "@react-native-community/netinfo"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils"; import { SurveyStore } from "@/lib/survey/store"; import type { TEnvironmentStateSurvey } from "@/types/config"; import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error"; +import { fetch } from "@react-native-community/netinfo"; /** * Triggers the display of a survey if it meets the display percentage criteria diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts index 9419fc6c37..e015737115 100644 --- a/packages/react-native/src/lib/survey/tests/action.test.ts +++ b/packages/react-native/src/lib/survey/tests/action.test.ts @@ -1,10 +1,10 @@ -import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils"; import { track, trackAction, triggerSurvey } from "@/lib/survey/action"; import { SurveyStore } from "@/lib/survey/store"; import { type TEnvironmentStateSurvey } from "@/types/config"; +import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@/lib/common/config", () => ({ RNConfig: { diff --git a/packages/types/api-key.ts b/packages/types/api-key.ts new file mode 100644 index 0000000000..fbcb5a4df1 --- /dev/null +++ b/packages/types/api-key.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +enum OrganizationAccessType { + Read = "read", + Write = "write", +} + +enum OrganizationAccess { + AccessControl = "accessControl", +} + +const organizationAccessTypeValues = Object.values(OrganizationAccessType); + +const organizationAccessTypeShape = organizationAccessTypeValues.reduce>( + (acc, enumKey) => { + acc[enumKey] = z.boolean(); + return acc; + }, + {} +); + +const organizationAccessValues = Object.values(OrganizationAccess); + +export const ZOrganizationAccess = z.object( + organizationAccessValues.reduce>>>( + (acc, access) => { + acc[access] = z.object(organizationAccessTypeShape).strict(); + return acc; + }, + {} + ) +); + +export type TOrganizationAccess = z.infer; diff --git a/packages/types/auth.ts b/packages/types/auth.ts index a8dc9a7f84..aef9184868 100644 --- a/packages/types/auth.ts +++ b/packages/types/auth.ts @@ -1,3 +1,4 @@ +import { ApiKeyPermission } from "@prisma/client"; import { z } from "zod"; import { ZUser } from "./user"; @@ -5,10 +6,19 @@ export const ZAuthSession = z.object({ user: ZUser, }); +export const ZAPIKeyEnvironmentPermission = z.object({ + environmentId: z.string(), + permission: z.nativeEnum(ApiKeyPermission), +}); + +export type TAPIKeyEnvironmentPermission = z.infer; + export const ZAuthenticationApiKey = z.object({ type: z.literal("apiKey"), - environmentId: z.string(), + environmentPermissions: z.array(ZAPIKeyEnvironmentPermission), hashedApiKey: z.string(), + apiKeyId: z.string().optional(), + organizationId: z.string().optional(), }); export type TAuthSession = z.infer; diff --git a/packages/types/package.json b/packages/types/package.json index c4ecad7b87..9eb7766ea2 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -13,6 +13,7 @@ "@formbricks/database": "workspace:*" }, "dependencies": { + "@prisma/client": "6.0.1", "zod": "3.24.1", "zod-openapi": "4.2.4" } diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index f673bdb2d5..4e8a2eec37 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -2359,6 +2359,29 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType()) export type TSurvey = z.infer; +export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurvey.innerType()) + .omit({ + id: true, + createdAt: true, + updatedAt: true, + projectOverwrites: true, + languages: true, + followUps: true, + }) + .extend({ + name: z.string(), // Keep name required + environmentId: z.string(), + questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation + languages: z.array(ZSurveyLanguage).default([]), + welcomeCard: ZSurveyWelcomeCard.default({ + enabled: false, + }), + endings: ZSurveyEndings.default([]), + type: ZSurveyType.default("link"), + followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]), + }) + .superRefine(ZSurvey._def.effect.type === "refinement" ? ZSurvey._def.effect.refinement : () => null); + export interface TSurveyDates { createdAt: TSurvey["createdAt"]; updatedAt: TSurvey["updatedAt"]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81f25c306e..3b48d2a222 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -598,6 +598,9 @@ importers: '@vitest/coverage-v8': specifier: 2.1.8 version: 2.1.8(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)) + resize-observer-polyfill: + specifier: 1.5.1 + version: 1.5.1 vite: specifier: 6.2.3 version: 6.2.3(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.29.2)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) @@ -1041,6 +1044,9 @@ importers: packages/types: dependencies: + '@prisma/client': + specifier: 6.0.1 + version: 6.0.1(prisma@6.5.0(typescript@5.8.2)) zod: specifier: 3.24.1 version: 3.24.1