mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
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:
committed by
GitHub
parent
1f039d707c
commit
1b9d91f1e8
@@ -1,3 +0,0 @@
|
||||
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
|
||||
|
||||
export default APIKeysLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -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} />;
|
||||
|
||||
147
apps/web/app/api/v1/auth.test.ts
Normal file
147
apps/web/app/api/v1/auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal 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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
|
||||
surveyIds: true,
|
||||
triggers: true,
|
||||
url: true,
|
||||
environmentId: true,
|
||||
});
|
||||
|
||||
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
||||
|
||||
18
apps/web/lib/cache/api-key.ts
vendored
18
apps/web/lib/cache/api-key.ts
vendored
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -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" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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." })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
178
apps/web/modules/organization/settings/api-keys/lib/api-key.ts
Normal file
178
apps/web/modules/organization/settings/api-keys/lib/api-key.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
51
apps/web/modules/organization/settings/api-keys/lib/utils.ts
Normal file
51
apps/web/modules/organization/settings/api-keys/lib/utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
52
apps/web/modules/organization/settings/api-keys/page.tsx
Normal file
52
apps/web/modules/organization/settings/api-keys/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
"prettier --write"
|
||||
],
|
||||
"*.json": [
|
||||
"prettier --write"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApiKey" ADD COLUMN "organizationAccess" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user