From 7b764c8427c97b09155774aab25f1778ad1d89b3 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:23:14 +0530 Subject: [PATCH] fix: adds api_key label to the view permission modal (#5326) --- .../organization/settings/api-keys/actions.ts | 30 ++- .../components/edit-api-keys.test.tsx | 49 +++- .../api-keys/components/edit-api-keys.tsx | 40 ++- .../components/view-permission-modal.test.tsx | 2 +- .../components/view-permission-modal.tsx | 253 +++++++++++------- .../settings/api-keys/lib/api-key.ts | 27 ++ .../settings/api-keys/lib/api-keys.test.ts | 27 +- .../settings/api-keys/types/api-keys.ts | 8 + packages/lib/messages/de-DE.json | 1 + packages/lib/messages/en-US.json | 1 + packages/lib/messages/fr-FR.json | 1 + packages/lib/messages/pt-BR.json | 1 + packages/lib/messages/pt-PT.json | 1 + packages/lib/messages/zh-Hant-TW.json | 1 + 14 files changed, 341 insertions(+), 101 deletions(-) diff --git a/apps/web/modules/organization/settings/api-keys/actions.ts b/apps/web/modules/organization/settings/api-keys/actions.ts index 8856319243..0969fd9057 100644 --- a/apps/web/modules/organization/settings/api-keys/actions.ts +++ b/apps/web/modules/organization/settings/api-keys/actions.ts @@ -3,10 +3,14 @@ import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; import { getOrganizationIdFromApiKeyId } from "@/lib/utils/helper"; -import { createApiKey, deleteApiKey } from "@/modules/organization/settings/api-keys/lib/api-key"; +import { + createApiKey, + deleteApiKey, + updateApiKey, +} 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"; +import { ZApiKeyCreateInput, ZApiKeyUpdateInput } from "./types/api-keys"; const ZDeleteApiKeyAction = z.object({ id: ZId, @@ -50,3 +54,25 @@ export const createApiKeyAction = authenticatedActionClient return await createApiKey(parsedInput.organizationId, ctx.user.id, parsedInput.apiKeyData); }); + +const ZUpdateApiKeyAction = z.object({ + apiKeyId: ZId, + apiKeyData: ZApiKeyUpdateInput, +}); + +export const updateApiKeyAction = authenticatedActionClient + .schema(ZUpdateApiKeyAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromApiKeyId(parsedInput.apiKeyId), + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + + return await updateApiKey(parsedInput.apiKeyId, parsedInput.apiKeyData); + }); diff --git a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx index c8ba9e5be3..94d558f3fc 100644 --- a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.test.tsx @@ -3,15 +3,16 @@ 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 { afterEach, describe, expect, it, test, vi } from "vitest"; import { TProject } from "@formbricks/types/project"; -import { createApiKeyAction, deleteApiKeyAction } from "../actions"; +import { createApiKeyAction, deleteApiKeyAction, updateApiKeyAction } from "../actions"; import { TApiKeyWithEnvironmentPermission } from "../types/api-keys"; import { EditAPIKeys } from "./edit-api-keys"; // Mock the actions vi.mock("../actions", () => ({ createApiKeyAction: vi.fn(), + updateApiKeyAction: vi.fn(), deleteApiKeyAction: vi.fn(), })); @@ -177,6 +178,50 @@ describe("EditAPIKeys", () => { expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_deleted"); }); + test("handles API key updation", async () => { + const updatedApiKey: TApiKeyWithEnvironmentPermission = { + id: "key1", + label: "Updated Key", + createdAt: new Date(), + organizationAccess: { + accessControl: { + read: true, + write: false, + }, + }, + apiKeyEnvironments: [ + { + environmentId: "env1", + permission: ApiKeyPermission.read, + }, + ], + }; + (updateApiKeyAction as unknown as ReturnType).mockResolvedValue({ data: updatedApiKey }); + render(); + + // Open view permission modal + const apiKeyRows = screen.getAllByTestId("api-key-row"); + + // click on the first row + await userEvent.click(apiKeyRows[0]); + + const labelInput = screen.getByTestId("api-key-label"); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, "Updated Key"); + + const submitButton = screen.getByRole("button", { name: "common.update" }); + await userEvent.click(submitButton); + + expect(updateApiKeyAction).toHaveBeenCalledWith({ + apiKeyId: "key1", + apiKeyData: { + label: "Updated Key", + }, + }); + + expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_updated"); + }); + it("handles API key creation", async () => { const newApiKey: TApiKeyWithEnvironmentPermission = { id: "key3", diff --git a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx index a11ce6e60a..4413273c0f 100644 --- a/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx @@ -3,6 +3,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/components/view-permission-modal"; import { + TApiKeyUpdateInput, TApiKeyWithEnvironmentPermission, TOrganizationProject, } from "@/modules/organization/settings/api-keys/types/api-keys"; @@ -16,7 +17,7 @@ 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 { createApiKeyAction, deleteApiKeyAction, updateApiKeyAction } from "../actions"; import { AddApiKeyModal } from "./add-api-key-modal"; interface EditAPIKeysProps { @@ -89,6 +90,38 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje setIsAddAPIKeyModalOpen(false); }; + const handleUpdateAPIKey = async (data: TApiKeyUpdateInput) => { + if (!activeKey) return; + + const updateApiKeyResponse = await updateApiKeyAction({ + apiKeyId: activeKey.id, + apiKeyData: data, + }); + + if (updateApiKeyResponse?.data) { + const updatedApiKeys = + apiKeysLocal?.map((apiKey) => { + if (apiKey.id === activeKey.id) { + return { + ...apiKey, + label: data.label, + }; + } + return apiKey; + }) || []; + + setApiKeysLocal(updatedApiKeys); + toast.success(t("environments.project.api_keys.api_key_updated")); + setIsLoading(false); + } else { + const errorMessage = getFormattedErrorMessage(updateApiKeyResponse); + toast.error(errorMessage); + setIsLoading(false); + } + + setViewPermissionsOpen(false); + }; + const ApiKeyDisplay = ({ apiKey }) => { const copyToClipboard = () => { navigator.clipboard.writeText(apiKey); @@ -129,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
{apiKeysLocal?.length === 0 ? ( -
+
{t("environments.project.api_keys.no_api_keys_yet")}
) : ( @@ -149,6 +182,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje } }} tabIndex={0} + data-testid="api-key-row" key={apiKey.id}>
{apiKey.label}
@@ -198,8 +232,10 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje )} { it("renders the modal with correct title", () => { render(); // Check the localized text for the modal's title - expect(screen.getByText("environments.project.api_keys.api_key")).toBeInTheDocument(); + expect(screen.getByText(mockApiKey.label)).toBeInTheDocument(); }); it("renders all permissions for the API key", () => { diff --git a/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx index c016d9bbb8..7c69d6869d 100644 --- a/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/view-permission-modal.tsx @@ -2,25 +2,62 @@ import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils"; import { + TApiKeyUpdateInput, TApiKeyWithEnvironmentPermission, TOrganizationProject, + ZApiKeyUpdateInput, } from "@/modules/organization/settings/api-keys/types/api-keys"; +import { Button } from "@/modules/ui/components/button"; import { DropdownMenu, 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 { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; -import { Fragment } from "react"; +import { Fragment, useEffect } from "react"; +import { useForm } from "react-hook-form"; import { TOrganizationAccess } from "@formbricks/types/api-key"; interface ViewPermissionModalProps { open: boolean; setOpen: (v: boolean) => void; + onSubmit: (data: TApiKeyUpdateInput) => Promise; apiKey: TApiKeyWithEnvironmentPermission; projects: TOrganizationProject[]; + isUpdating: boolean; } -export const ViewPermissionModal = ({ open, setOpen, apiKey, projects }: ViewPermissionModalProps) => { +export const ViewPermissionModal = ({ + open, + setOpen, + onSubmit, + apiKey, + projects, + isUpdating, +}: ViewPermissionModalProps) => { + const { register, getValues, handleSubmit, reset, watch } = useForm({ + defaultValues: { + label: apiKey.label, + }, + resolver: zodResolver(ZApiKeyUpdateInput), + }); + + useEffect(() => { + reset({ label: apiKey.label }); + }, [apiKey.label, reset]); + + const apiKeyLabel = watch("label"); + + const isSubmitDisabled = () => { + // Check if label is empty or only whitespace or if the label is the same as the original + if (!apiKeyLabel?.trim() || apiKeyLabel === apiKey.label) { + return true; + } + + return false; + }; + const { t } = useTranslate(); const organizationAccess = apiKey.organizationAccess as TOrganizationAccess; @@ -34,116 +71,146 @@ export const ViewPermissionModal = ({ open, setOpen, apiKey, projects }: ViewPer ?.environments.find((env) => env.id === environmentId)?.type; }; + const updateApiKey = async () => { + const data = getValues(); + await onSubmit(data); + reset(); + }; + return (
-
- {t("environments.project.api_keys.api_key")} -
+
{apiKey.label}
-
-
-
- +
+
+
- {/* Permission rows */} - {apiKey.apiKeyEnvironments?.map((permission) => { - return ( -
- {/* Project dropdown */} -
- - - - - -
- - {/* Environment dropdown */} -
- - - - - -
- - {/* Permission level dropdown */} -
- - - - - -
-
- ); - })} + + value.trim() !== "" })} + />
-
- -
-
-
-
- Read - Write + +
+ {/* Permission rows */} + {apiKey.apiKeyEnvironments?.map((permission) => { + return ( +
+ {/* Project dropdown */} +
+ + + + + +
- {Object.keys(organizationAccess).map((key) => ( - -
{t(getOrganizationAccessKeyDisplayName(key))}
-
- + {/* Environment dropdown */} +
+ + + + + +
+ + {/* Permission level dropdown */} +
+ + + + + +
-
- -
-
- ))} + ); + })} +
+
+ +
+ +
+
+
+ Read + Write + + {Object.keys(organizationAccess).map((key) => ( + +
{t(getOrganizationAccessKeyDisplayName(key))}
+
+ +
+
+ +
+
+ ))} +
-
+
+
+ + +
+
+
diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts index 0a2c8370bd..e5dbfa3929 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts @@ -2,6 +2,7 @@ import "server-only"; import { apiKeyCache } from "@/lib/cache/api-key"; import { TApiKeyCreateInput, + TApiKeyUpdateInput, TApiKeyWithEnvironmentPermission, ZApiKeyCreateInput, } from "@/modules/organization/settings/api-keys/types/api-keys"; @@ -193,3 +194,29 @@ export const createApiKey = async ( throw error; } }; + +export const updateApiKey = async (apiKeyId: string, data: TApiKeyUpdateInput): Promise => { + try { + const updatedApiKey = await prisma.apiKey.update({ + where: { + id: apiKeyId, + }, + data: { + label: data.label, + }, + }); + + apiKeyCache.revalidate({ + id: updatedApiKey.id, + hashedKey: updatedApiKey.hashedKey, + organizationId: updatedApiKey.organizationId, + }); + + return updatedApiKey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts index 87e3b2dcc5..267d31d9ec 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts @@ -4,7 +4,7 @@ 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"; +import { createApiKey, deleteApiKey, getApiKeysWithEnvironmentPermissions, updateApiKey } from "./api-key"; const mockApiKey: ApiKey = { id: "apikey123", @@ -39,6 +39,7 @@ vi.mock("@formbricks/database", () => ({ findMany: vi.fn(), delete: vi.fn(), create: vi.fn(), + update: vi.fn(), }, }, })); @@ -191,4 +192,28 @@ describe("API Key Management", () => { await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(DatabaseError); }); }); + + describe("updateApiKey", () => { + it("updates an API key successfully", async () => { + const updatedApiKey = { ...mockApiKey, label: "Updated API Key" }; + vi.mocked(prisma.apiKey.update).mockResolvedValueOnce(updatedApiKey); + + const result = await updateApiKey(mockApiKey.id, { label: "Updated API Key" }); + + expect(result).toEqual(updatedApiKey); + expect(prisma.apiKey.update).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.update).mockRejectedValueOnce(errToThrow); + + await expect(updateApiKey(mockApiKey.id, { label: "Updated API Key" })).rejects.toThrow(DatabaseError); + }); + }); }); diff --git a/apps/web/modules/organization/settings/api-keys/types/api-keys.ts b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts index 7fa5a986c6..ef84af1550 100644 --- a/apps/web/modules/organization/settings/api-keys/types/api-keys.ts +++ b/apps/web/modules/organization/settings/api-keys/types/api-keys.ts @@ -22,6 +22,14 @@ export const ZApiKeyCreateInput = ZApiKey.required({ export type TApiKeyCreateInput = z.infer; +export const ZApiKeyUpdateInput = ZApiKey.required({ + label: true, +}).pick({ + label: true, +}); + +export type TApiKeyUpdateInput = z.infer; + export interface TApiKey extends ApiKey { apiKey?: string; } diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json index a589efd26d..933896b57d 100644 --- a/packages/lib/messages/de-DE.json +++ b/packages/lib/messages/de-DE.json @@ -785,6 +785,7 @@ "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.", + "api_key_updated": "API-Schlüssel aktualisiert", "duplicate_access": "Doppelter Projektzugriff nicht erlaubt", "no_api_keys_yet": "Du hast noch keine API-Schlüssel", "organization_access": "Organisationszugang", diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json index c6b2bcf8ba..e4bc7cf882 100644 --- a/packages/lib/messages/en-US.json +++ b/packages/lib/messages/en-US.json @@ -785,6 +785,7 @@ "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.", + "api_key_updated": "API Key updated", "duplicate_access": "Duplicate project access not allowed", "no_api_keys_yet": "You don't have any API keys yet", "organization_access": "Organization Access", diff --git a/packages/lib/messages/fr-FR.json b/packages/lib/messages/fr-FR.json index 8d03b0f7bd..f0b9f79754 100644 --- a/packages/lib/messages/fr-FR.json +++ b/packages/lib/messages/fr-FR.json @@ -785,6 +785,7 @@ "api_key_deleted": "Clé API supprimée", "api_key_label": "Étiquette de clé API", "api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.", + "api_key_updated": "Clé API mise à jour", "duplicate_access": "L'accès en double au projet n'est pas autorisé", "no_api_keys_yet": "Vous n'avez pas encore de clés API.", "organization_access": "Accès à l'organisation", diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json index 31e0f4c4da..290155cfcd 100644 --- a/packages/lib/messages/pt-BR.json +++ b/packages/lib/messages/pt-BR.json @@ -785,6 +785,7 @@ "api_key_deleted": "Chave da API deletada", "api_key_label": "Rótulo da Chave API", "api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "api_key_updated": "Chave de API atualizada", "duplicate_access": "Acesso duplicado ao projeto não permitido", "no_api_keys_yet": "Você ainda não tem nenhuma chave de API", "organization_access": "Acesso à Organização", diff --git a/packages/lib/messages/pt-PT.json b/packages/lib/messages/pt-PT.json index cde03332f4..a6e634c601 100644 --- a/packages/lib/messages/pt-PT.json +++ b/packages/lib/messages/pt-PT.json @@ -785,6 +785,7 @@ "api_key_deleted": "Chave API eliminada", "api_key_label": "Etiqueta da Chave API", "api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "api_key_updated": "Chave API atualizada", "duplicate_access": "Acesso duplicado ao projeto não permitido", "no_api_keys_yet": "Ainda não tem nenhuma chave API", "organization_access": "Acesso à Organização", diff --git a/packages/lib/messages/zh-Hant-TW.json b/packages/lib/messages/zh-Hant-TW.json index 9459e98bc6..6cb65cddac 100644 --- a/packages/lib/messages/zh-Hant-TW.json +++ b/packages/lib/messages/zh-Hant-TW.json @@ -785,6 +785,7 @@ "api_key_deleted": "API 金鑰已刪除", "api_key_label": "API 金鑰標籤", "api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。", + "api_key_updated": "API 金鑰已更新", "duplicate_access": "不允許重複的 project 存取", "no_api_keys_yet": "您還沒有任何 API 金鑰", "organization_access": "組織 Access",