chore: Api keys to org level (#5044)

Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2025-04-03 18:29:42 +05:30
committed by GitHub
parent 1f039d707c
commit 1b9d91f1e8
115 changed files with 3828 additions and 1161 deletions

View File

@@ -1,3 +0,0 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;

View File

@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -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 <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}

View File

@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -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 <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

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

View File

@@ -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<TAuthenticationApiKey | null> => {
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 => {

View File

@@ -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<string | null> => {
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)],
}
)();
});

View File

@@ -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<TActionClass | null> => {
// 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);
}

View File

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

View File

@@ -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<TActionClass[]> =>
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)),
}
)()
);

View File

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

View File

@@ -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) {

View File

@@ -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<TResponse> => {
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<boolean> => {
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();

View File

@@ -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<TRe
throw error;
}
};
export const getResponsesByEnvironmentIds = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
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)),
}
)()
);

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
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) {

View File

@@ -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<TSurvey | null> => {
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,
});

View File

@@ -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) {

View File

@@ -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<TSurvey[]> =>
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<TSurvey>(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)),
}
)()
);

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
);
}
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<Response> => {
}
}
const survey = await createSurvey(environmentId, surveyData);
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {

View File

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

View File

@@ -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<Webhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
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<Webhook[]> =>
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
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<Webho
throw error;
}
},
[`getWebhooks-${environmentId}-${page}`],
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
}
)();

View File

@@ -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) {

View File

@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
surveyIds: true,
triggers: true,
url: true,
environmentId: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
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);
};

View File

@@ -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<void, ApiErrorResponseV2> => {
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
return err({
type: "unauthorized",
});
}
return okVoid();
};

View File

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

View File

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

View File

@@ -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<Result<string, ApiErrorResponseV2>> => {
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)],
}
)();
});

View File

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

View File

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

View File

@@ -130,16 +130,16 @@ export const createResponse = async (
};
export const getResponses = async (
environmentId: string,
environmentIds: string[],
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, 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,
}),
]);

View File

@@ -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<Prisma.ResponseFindManyArgs>({
where: {
survey: { environmentId: "env-id" },
survey: { environmentId: { in: ["env-id"] } },
surveyId: "test",
},
}),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Result<ApiResponseWithMeta<Webhook[]>, 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,
}),
]);

View File

@@ -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) {

View File

@@ -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<TContactAttributeKey | null> => {
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);
}
};

View File

@@ -14,12 +14,12 @@ import {
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
export const getContactAttributeKeys = reactCache(
(environmentId: string): Promise<TContactAttributeKey[]> =>
(environmentIds: string[]): Promise<TContactAttributeKey[]> =>
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)),
}
)()
);

View File

@@ -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<Response> => {
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
);

View File

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

View File

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

View File

@@ -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) {

View File

@@ -9,14 +9,14 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getContacts = reactCache(
(environmentId: string): Promise<TContact[]> =>
(environmentIds: string[]): Promise<TContact[]> =>
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)),
}
)()
);

View File

@@ -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) {

View File

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

View File

@@ -123,6 +123,7 @@ export const ZContactBulkUploadContact = z.object({
export type TContactBulkUploadContact = z.infer<typeof ZContactBulkUploadContact>;
export const ZContactBulkUploadRequest = z.object({
environmentId: z.string().cuid2(),
contacts: z
.array(ZContactBulkUploadContact)
.max(1000, { message: "Maximum 1000 contacts allowed at a time." })

View File

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

View File

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

View File

@@ -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<void>;
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<TOrganizationAccess>(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<string, PermissionRecord>;
};
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>(() =>
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 (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api_keys.add_api_key")}
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitAPIKey)}>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
const permission = selectedPermissions[key];
return (
<div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">{permission.projectName}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem]">
{projectOptions.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.environmentType}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button
type="button"
className="p-2"
onClick={() => removePermission(permissionIndex)}
disabled={Object.keys(selectedPermissions).length <= 1}>
<Trash2Icon
className={`h-5 w-5 ${
Object.keys(selectedPermissions).length <= 1
? "text-slate-300"
: "text-slate-500 hover:text-red-500"
}`}
/>
</button>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div>
</div>
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>{t("environments.project.api_keys.api_key_security_warning")}</p>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
reset();
setSelectedPermissions(getInitialPermissions());
}}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitDisabled() || isCreatingAPIKey}
loading={isCreatingAPIKey}>
{t("environments.project.api_keys.add_api_key")}
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
});
});

View File

@@ -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 (
<EditAPIKeys
organizationId={organizationId}
apiKeys={apiKeys}
locale={locale}
isReadOnly={isReadOnly}
projects={projects}
/>
);
};

View File

@@ -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(<EditAPIKeys {...defaultProps} />);
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(<EditAPIKeys {...defaultProps} apiKeys={[]} />);
expect(screen.getByText("environments.project.api_keys.no_api_keys_yet")).toBeInTheDocument();
});
it("shows add API key button when not readonly", () => {
render(<EditAPIKeys {...defaultProps} />);
expect(
screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" })
).toBeInTheDocument();
});
it("hides add API key button when readonly", () => {
render(<EditAPIKeys {...defaultProps} isReadOnly={true} />);
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(<EditAPIKeys {...defaultProps} />);
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<typeof vi.fn>).mockResolvedValue({ data: true });
render(<EditAPIKeys {...defaultProps} />);
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<typeof vi.fn>).mockResolvedValue({ data: newApiKey });
render(<EditAPIKeys {...defaultProps} />);
// 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(<EditAPIKeys {...defaultProps} apiKeys={[apiKeyWithActual]} />);
// 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");
});
});

View File

@@ -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<TApiKeyWithEnvironmentPermission | null>(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<void> => {
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 <span className="italic">{t("environments.project.api_keys.secret")}</span>;
}
return (
<div className="flex items-center">
<span>{apiKey}</span>
<div className="copyApiKeyIcon">
<FilesIcon
className="mx-2 h-4 w-4 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
copyToClipboard();
}}
data-testid="copy-button"
/>
</div>
</div>
);
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api_keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.project.api_keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal?.map((apiKey) => (
<div
role="button"
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none"
onClick={() => {
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveKey(apiKey);
setViewPermissionsOpen(true);
}
}}
tabIndex={0}
key={apiKey.id}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.actualKey} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
handleOpenDeleteKeyModal(e, apiKey);
e.stopPropagation();
}}>
<TrashIcon />
</Button>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<Button
size="sm"
onClick={() => {
setIsAddAPIKeyModalOpen(true);
}}>
{t("environments.settings.api_keys.add_api_key")}
</Button>
</div>
)}
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setIsAddAPIKeyModalOpen}
onSubmit={handleAddAPIKey}
projects={projects}
isCreatingAPIKey={isLoading}
/>
{activeKey && (
<ViewPermissionModal
open={viewPermissionsOpen}
setOpen={setViewPermissionsOpen}
apiKey={activeKey}
projects={projects}
/>
)}
<DeleteDialog
open={isDeleteKeyModalOpen}
setOpen={setIsDeleteKeyModalOpen}
deleteWhat={t("environments.project.api_keys.api_key")}
onDelete={handleDeleteKey}
isDeleting={isLoading}
/>
</div>
);
};

View File

@@ -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(<ViewPermissionModal {...defaultProps} />);
// 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(<ViewPermissionModal {...defaultProps} />);
// 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(<ViewPermissionModal {...defaultProps} />);
// 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(<ViewPermissionModal {...defaultProps} />);
// 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(<ViewPermissionModal {...defaultProps} apiKey={apiKeyWithoutPermissions} />);
// 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(<ViewPermissionModal {...defaultProps} apiKey={mockApiKeyWithOrgAccess} />);
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();
});
});

View File

@@ -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 (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api_keys.api_key")}
</div>
</div>
</div>
</div>
<div>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.permissions")}</Label>
<div className="space-y-2">
{/* Permission rows */}
{apiKey.apiKeyEnvironments?.map((permission) => {
return (
<div key={permission.environmentId} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getProjectName(permission.environmentId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{getEnvironmentName(permission.environmentId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};

View File

@@ -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<TApiKeyWithEnvironmentPermission[]> =>
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<ApiKey | null> => {
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<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
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;
}
};

View File

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

View File

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

View File

@@ -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<TOrganizationProject[]> =>
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)],
}
)()
);

View File

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

View File

@@ -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 = () => {
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
{t("environments.project.api_keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
</div>
@@ -38,15 +38,17 @@ const LoadingCard = () => {
);
};
export const APIKeysLoading = () => {
const Loading = ({ isFormbricksCloud }: { isFormbricksCloud: boolean }) => {
const { t } = useTranslate();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation activeId="api-keys" loading />
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={isFormbricksCloud} activeId="api-keys" loading />
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
<LoadingCard />
</PageContentWrapper>
);
};
export default Loading;

View File

@@ -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 (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="api-keys"
/>
</PageHeader>
{isReadOnly ? (
<Alert variant="warning">
{t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")}
</Alert>
) : (
<SettingsCard
title={t("common.api_keys")}
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale}
isReadOnly={isReadOnly}
projects={projects}
/>
</SettingsCard>
)}
</PageContentWrapper>
);
};

View File

@@ -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<typeof ZApiKeyCreateInput>;
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<typeof OrganizationProject>;
export const TApiKeyEnvironmentPermission = z.object({
environmentId: z.string(),
permission: z.nativeEnum(ApiKeyPermission),
});
export type TApiKeyEnvironmentPermission = z.infer<typeof TApiKeyEnvironmentPermission>;
export interface TApiKeyWithEnvironmentPermission
extends Pick<ApiKey, "id" | "label" | "createdAt" | "organizationAccess"> {
apiKeyEnvironments: TApiKeyEnvironmentPermission[];
}

View File

@@ -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 (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api-keys.add_api_key")}
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitAPIKey)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<Label>{t("environments.project.api-keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>{t("environments.project.api-keys.api_key_security_warning")}</p>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.project.api-keys.add_api_key")}</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};

View File

@@ -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 (
<EditAPIKeys
environmentTypeId={environmentTypeId}
environmentType={environmentType}
apiKeys={apiKeys}
environmentId={environmentId}
locale={locale}
isReadOnly={isReadOnly}
/>
);
};

View File

@@ -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<TApiKey[]>(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 <span className="italic">{t("environments.project.api-keys.secret")}</span>;
}
return (
<div className="flex items-center">
<span>{apiKey}</span>
<div className="copyApiKeyIcon">
<FilesIcon className="mx-2 h-4 w-4 cursor-pointer" onClick={copyToClipboard} />
</div>
</div>
);
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal && apiKeysLocal.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.project.api-keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal &&
apiKeysLocal.map((apiKey) => (
<div
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900"
key={apiKey.hashedKey}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.apiKey} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button size="icon" variant="ghost" onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}>
<TrashIcon />
</Button>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<Button
size="sm"
disabled={environmentId !== environmentTypeId}
onClick={() => {
setOpenAddAPIKeyModal(true);
}}>
{t("environments.project.api-keys.add_env_api_key", { environmentType })}
</Button>
</div>
)}
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setOpenAddAPIKeyModal}
onSubmit={handleAddAPIKey}
/>
<DeleteDialog
open={isDeleteKeyModalOpen}
setOpen={setOpenDeleteKeyModal}
deleteWhat={t("environments.project.api-keys.api_key")}
onDelete={handleDeleteKey}
/>
</div>
);
};

View File

@@ -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<ApiKey[]> =>
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<ApiKey | null> => {
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<TApiKey> => {
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;
}
};

View File

@@ -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 (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="api-keys" />
</PageHeader>
<EnvironmentNotice environmentId={environment.id} subPageUrl="/project/api-keys" />
{environment.type === "development" ? (
<SettingsCard
title={t("environments.project.api-keys.dev_api_keys")}
description={t("environments.project.api-keys.dev_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="development"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
) : (
<SettingsCard
title={t("environments.project.api-keys.prod_api_keys")}
description={t("environments.project.api-keys.prod_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="production"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
)}
</PageContentWrapper>
);
};

View File

@@ -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<typeof ZApiKeyCreateInput>;
export interface TApiKey extends ApiKey {
apiKey?: string;
}

View File

@@ -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: <KeyIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/api-keys`,
current: pathname?.includes("/api-keys"),
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,8 +49,7 @@
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
"prettier --write",
"eslint --fix"
"prettier --write"
],
"*.json": [
"prettier --write"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ApiKey" ADD COLUMN "organizationAccess" JSONB NOT NULL DEFAULT '{}';

View File

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

View File

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

View File

@@ -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<void> => {
const runSingleMigration = async (migration: MigrationScript, index: number): Promise<void> => {
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;
}

View File

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

View File

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

View File

@@ -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", () => ({

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

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

View File

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

View File

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

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