Rewrite Api Key Settings to React Server Components (#654)

* moved apikey settings to server component

* rename ZApiKeyData to ZApiKeyCreateInput

* Make smaller improvements

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-08-11 20:53:59 +05:30
committed by GitHub
parent c6686209be
commit 47a8fd6b62
7 changed files with 163 additions and 41 deletions

View File

@@ -1,19 +1,15 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { ErrorComponent } from "@formbricks/ui";
import EditApiKeys from "./EditApiKeys";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getApiKeys } from "@formbricks/lib/services/apiKey";
import { getEnvironments } from "@formbricks/lib/services/environment";
export default function ApiKeyList({
export default async function ApiKeyList({
environmentId,
environmentType,
}: {
environmentId: string;
environmentType: string;
}) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const findEnvironmentByType = (environments, targetType) => {
for (const environment of environments) {
if (environment.type === targetType) {
@@ -23,15 +19,12 @@ export default function ApiKeyList({
return null;
};
if (isLoadingProduct) {
return <LoadingSpinner />;
}
const product = await getProductByEnvironmentId(environmentId);
const environments = await getEnvironments(product.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);
if (isErrorProduct) {
<ErrorComponent />;
}
const environmentTypeId = findEnvironmentByType(product?.environments, environmentType);
return <EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} />;
return (
<EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} apiKeys={apiKeys} />
);
}

View File

@@ -1,28 +1,28 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { createApiKey, deleteApiKey, useApiKeys } from "@/lib/apiKeys";
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { Button, ErrorComponent } from "@formbricks/ui";
import { TApiKey } from "@formbricks/types/v1/apiKeys";
import { Button } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import toast from "react-hot-toast";
import AddAPIKeyModal from "./AddApiKeyModal";
import { createApiKeyAction, deleteApiKeyAction } from "./actions";
export default function EditAPIKeys({
environmentTypeId,
environmentType,
apiKeys,
}: {
environmentTypeId: string;
environmentType: string;
apiKeys: TApiKey[];
}) {
const { apiKeys, mutateApiKeys, isLoadingApiKeys, isErrorApiKeys } = useApiKeys(environmentTypeId);
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) => {
@@ -32,26 +32,21 @@ export default function EditAPIKeys({
};
const handleDeleteKey = async () => {
await deleteApiKey(environmentTypeId, activeKey);
mutateApiKeys();
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
setOpenDeleteKeyModal(false);
toast.success("API Key deleted");
};
const handleAddAPIKey = async (data) => {
const apiKey = await createApiKey(environmentTypeId, { label: data.label });
mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false);
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
setOpenAddAPIKeyModal(false);
toast.success("API key created");
};
if (isLoadingApiKeys) {
return <LoadingSpinner />;
}
if (isErrorApiKeys) {
<ErrorComponent />;
}
return (
<>
<div className="mb-6 text-right">
@@ -72,19 +67,22 @@ export default function EditAPIKeys({
<div className=""></div>
</div>
<div className="grid-cols-9">
{apiKeys.length === 0 ? (
{apiKeysLocal && apiKeysLocal.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400 ">
You don&apos;t have any API keys yet
</div>
) : (
apiKeys.map((apiKey) => (
apiKeysLocal &&
apiKeysLocal.map((apiKey) => (
<div
className="grid h-12 w-full grid-cols-9 content-center rounded-lg px-6 text-left text-sm text-slate-900"
key={apiKey.hashedKey}>
<div className="col-span-2 font-semibold">{apiKey.label}</div>
<div className="col-span-2">{apiKey.apiKey || <span className="italic">secret</span>}</div>
<div className="col-span-2">{apiKey.lastUsed && timeSince(apiKey.lastUsed)}</div>
<div className="col-span-2">{timeSince(apiKey.createdAt)}</div>
<div className="col-span-2">
{apiKey.lastUsedAt && timeSince(apiKey.lastUsedAt.toString())}
</div>
<div className="col-span-2">{timeSince(apiKey.createdAt.toString())}</div>
<div className="col-span-1 text-center">
<button onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />

View File

@@ -0,0 +1,11 @@
"use server";
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
export async function deleteApiKeyAction(id: string) {
return await deleteApiKey(id);
}
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
return await createApiKey(environmentId, apiKeyData);
}

View File

@@ -0,0 +1,50 @@
function LoadingCard({ title, description }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
<div className="flex justify-end">
<div className="mt-4 h-6 w-28 animate-pulse rounded-full bg-gray-200"></div>
</div>
<div className="mt-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-9 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">Label</div>
<div className="col-span-2">API Key</div>
<div className="col-span-2">Last used</div>
<div className="col-span-2">Created at</div>
</div>
<div className="px-6">
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Development Env Keys",
description: "Add and remove API keys for your Development environment.",
},
{
title: "Production Env Keys",
description: "Add and remove API keys for your Production environment.",
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">API Keys</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,3 +1,6 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import ApiKeyList from "./ApiKeyList";

View File

@@ -1,8 +1,12 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { TApiKey } from "@formbricks/types/v1/apiKeys";
import { TApiKey, TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
import { Prisma } from "@prisma/client";
import { getHash } from "../crypto";
import { createHash, randomBytes } from "crypto";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/errors";
import { cache } from "react";
export const getApiKey = async (apiKey: string): Promise<TApiKey | null> => {
if (!apiKey) {
@@ -30,6 +34,47 @@ export const getApiKey = async (apiKey: string): Promise<TApiKey | null> => {
}
};
export const getApiKeys = cache(async (environmentId: string): Promise<TApiKey[]> => {
try {
const apiKeys = await prisma.apiKey.findMany({
where: {
environmentId,
},
});
return apiKeys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export async function createApiKey(environmentId: string, apiKeyData: TApiKeyCreateInput): Promise<TApiKey> {
try {
const key = randomBytes(16).toString("hex");
const hashedKey = hashApiKey(key);
const result = await prisma.apiKey.create({
data: {
...apiKeyData,
hashedKey,
environment: { connect: { id: environmentId } },
},
});
return { ...result, apiKey: key };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null> => {
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
@@ -51,3 +96,19 @@ export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null>
throw error;
}
};
export const deleteApiKey = async (id: string): Promise<void> => {
try {
await prisma.apiKey.delete({
where: {
id: id,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -7,6 +7,12 @@ export const ZApiKey = z.object({
label: z.string().nullable(),
hashedKey: z.string(),
environmentId: z.string().cuid2(),
apiKey: z.string().optional(),
});
export type TApiKey = z.infer<typeof ZApiKey>;
export const ZApiKeyCreateInput = z.object({
label: z.string(),
});
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;