Merge branch 'main' of https://github.com/formbricks/formbricks into chore/upgrade-web-tailwind4

This commit is contained in:
Piyush Gupta
2025-04-14 19:39:48 +05:30
14 changed files with 340 additions and 100 deletions

View File

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

View File

@@ -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<typeof vi.fn>).mockResolvedValue({ data: updatedApiKey });
render(<EditAPIKeys {...defaultProps} />);
// 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",

View File

@@ -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);
@@ -149,6 +182,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
}
}}
tabIndex={0}
data-testid="api-key-row"
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">
@@ -198,8 +232,10 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
<ViewPermissionModal
open={viewPermissionsOpen}
setOpen={setViewPermissionsOpen}
onSubmit={handleUpdateAPIKey}
apiKey={activeKey}
projects={projects}
isUpdating={isLoading}
/>
)}
<DeleteDialog

View File

@@ -109,7 +109,7 @@ describe("ViewPermissionModal", () => {
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();
expect(screen.getByText(mockApiKey.label)).toBeInTheDocument();
});
it("renders all permissions for the API key", () => {

View File

@@ -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<void>;
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<TApiKeyUpdateInput>({
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 (
<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 className="text-xl font-medium text-slate-700">{apiKey.label}</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>
<form onSubmit={handleSubmit(updateApiKey)}>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<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-hidden">
<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-hidden">
<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-hidden">
<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>
);
})}
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
data-testid="api-key-label"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</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>
<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-hidden">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getProjectName(permission.environmentId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
{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}
/>
{/* 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-hidden">
<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-hidden">
<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 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 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 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();
}}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitDisabled() || isUpdating} loading={isUpdating}>
{t("common.update")}
</Button>
</div>
</div>
</form>
</div>
</div>
</Modal>

View File

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

View File

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

View File

@@ -22,6 +22,14 @@ export const ZApiKeyCreateInput = ZApiKey.required({
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
export const ZApiKeyUpdateInput = ZApiKey.required({
label: true,
}).pick({
label: true,
});
export type TApiKeyUpdateInput = z.infer<typeof ZApiKeyUpdateInput>;
export interface TApiKey extends ApiKey {
apiKey?: string;
}

View File

@@ -784,6 +784,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",

View File

@@ -784,6 +784,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",

View File

@@ -784,6 +784,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",

View File

@@ -784,6 +784,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",

View File

@@ -784,6 +784,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",

View File

@@ -784,6 +784,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",