rcgit

/ karakeep

Commit 7671f4ff

SHA 7671f4ff7ac5b106c3faa6b59a01f154cb34be99
Author Mohamed Bassem <me at mbassem dot com>
Author Date 2025-09-14 16:31 +0000
Committer Mohamed Bassem <me at mbassem dot com>
Commit Date 2025-09-14 16:31 +0000
Parent(s) 69ef2ffe5e92 (diff)
Tree f9445fd05de1

patch snapshot

feat: Regen api keys
File + - Graph
M apps/web/components/settings/AddApiKey.tsx +5 -20
M apps/web/components/settings/ApiKeySettings.tsx +5 -1
A apps/web/components/settings/ApiKeySuccess.tsx +24 -0
A apps/web/components/settings/RegenerateApiKey.tsx +118 -0
M apps/web/lib/i18n/locales/en/translation.json +7 -1
M packages/trpc/auth.ts +36 -6
M packages/trpc/routers/apiKeys.test.ts +31 -0
M packages/trpc/routers/apiKeys.ts +33 -1
8 file(s) changed, 259 insertions(+), 29 deletions(-)

apps/web/components/settings/AddApiKey.tsx

diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx
index 326da229..c8baa626 100644
--- a/apps/web/components/settings/AddApiKey.tsx
+++ b/apps/web/components/settings/AddApiKey.tsx
@@ -5,7 +5,6 @@ import { useState } from "react";
 import { useRouter } from "next/navigation";
 import { ActionButton } from "@/components/ui/action-button";
 import { Button } from "@/components/ui/button";
-import CopyBtn from "@/components/ui/copy-button";
 import {
   Dialog,
   DialogClose,
@@ -33,24 +32,7 @@ import { PlusCircle } from "lucide-react";
 import { useForm } from "react-hook-form";
 import { z } from "zod";
 
-function ApiKeySuccess({ apiKey }: { apiKey: string }) {
-  const { t } = useTranslation();
-  return (
-    <div>
-      <div className="py-4 text-sm text-muted-foreground">
-        {t("settings.api_keys.key_success_please_copy")}
-      </div>
-      <div className="flex space-x-2 pt-2">
-        <Input value={apiKey} readOnly />
-        <CopyBtn
-          getStringToCopy={() => {
-            return apiKey;
-          }}
-        />
-      </div>
-    </div>
-  );
-}
+import ApiKeySuccess from "./ApiKeySuccess";
 
 function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
   const { t } = useTranslation();
@@ -145,7 +127,10 @@ export default function AddApiKey() {
           </DialogTitle>
         </DialogHeader>
         {key ? (
-          <ApiKeySuccess apiKey={key} />
+          <ApiKeySuccess
+            apiKey={key}
+            message={t("settings.api_keys.key_success")}
+          />
         ) : (
           <AddApiKeyForm onSuccess={setKey} />
         )}

apps/web/components/settings/ApiKeySettings.tsx

diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx
index 2b9d19d1..bc4b71c5 100644
--- a/apps/web/components/settings/ApiKeySettings.tsx
+++ b/apps/web/components/settings/ApiKeySettings.tsx
@@ -11,6 +11,7 @@ import { api } from "@/server/api/client";
 
 import AddApiKey from "./AddApiKey";
 import DeleteApiKey from "./DeleteApiKey";
+import RegenerateApiKey from "./RegenerateApiKey";
 
 export default async function ApiKeys() {
   // oxlint-disable-next-line rules-of-hooks
@@ -41,7 +42,10 @@ export default async function ApiKeys() {
                 <TableCell>**_{k.keyId}_**</TableCell>
                 <TableCell>{k.createdAt.toLocaleString()}</TableCell>
                 <TableCell>
-                  <DeleteApiKey name={k.name} id={k.id} />
+                  <div className="flex items-center gap-2">
+                    <RegenerateApiKey name={k.name} id={k.id} />
+                    <DeleteApiKey name={k.name} id={k.id} />
+                  </div>
                 </TableCell>
               </TableRow>
             ))}

apps/web/components/settings/ApiKeySuccess.tsx

diff --git a/apps/web/components/settings/ApiKeySuccess.tsx b/apps/web/components/settings/ApiKeySuccess.tsx
new file mode 100644
index 00000000..370d711b
--- /dev/null
+++ b/apps/web/components/settings/ApiKeySuccess.tsx
@@ -0,0 +1,24 @@
+import CopyBtn from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+
+export default function ApiKeySuccess({
+  apiKey,
+  message,
+}: {
+  apiKey: string;
+  message: string;
+}) {
+  return (
+    <div>
+      <div className="py-4 text-sm text-muted-foreground">{message}</div>
+      <div className="flex space-x-2 pt-2">
+        <Input value={apiKey} readOnly />
+        <CopyBtn
+          getStringToCopy={() => {
+            return apiKey;
+          }}
+        />
+      </div>
+    </div>
+  );
+}

apps/web/components/settings/RegenerateApiKey.tsx

diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx
new file mode 100644
index 00000000..1c034026
--- /dev/null
+++ b/apps/web/components/settings/RegenerateApiKey.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { RefreshCcw } from "lucide-react";
+
+import ApiKeySuccess from "./ApiKeySuccess";
+
+export default function RegenerateApiKey({
+  id,
+  name,
+}: {
+  id: string;
+  name: string;
+}) {
+  const { t } = useTranslation();
+  const router = useRouter();
+
+  const [key, setKey] = useState<string | undefined>(undefined);
+  const [dialogOpen, setDialogOpen] = useState<boolean>(false);
+
+  const mutator = api.apiKeys.regenerate.useMutation({
+    onSuccess: (resp) => {
+      setKey(resp.key);
+      router.refresh();
+    },
+    onError: () => {
+      toast({
+        description: t("common.something_went_wrong"),
+        variant: "destructive",
+      });
+      setDialogOpen(false);
+    },
+  });
+
+  const handleRegenerate = () => {
+    mutator.mutate({ id });
+  };
+
+  return (
+    <Dialog
+      open={dialogOpen}
+      onOpenChange={(o) => {
+        setDialogOpen(o);
+        setKey(undefined);
+      }}
+    >
+      <DialogTrigger asChild>
+        <Button variant="ghost" size="sm" title="Regenerate">
+          <RefreshCcw className="h-4 w-4" />
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>
+            {key
+              ? t("settings.api_keys.key_regenerated")
+              : t("settings.api_keys.regenerate_api_key")}
+          </DialogTitle>
+          {!key && (
+            <DialogDescription>
+              {t("settings.api_keys.regenerate_warning", { name })}
+            </DialogDescription>
+          )}
+        </DialogHeader>
+        {key ? (
+          <ApiKeySuccess
+            apiKey={key}
+            message={t("settings.api_keys.key_regenerated_please_copy")}
+          />
+        ) : (
+          <p className="text-sm">
+            {t("settings.api_keys.regenerate_confirmation")}
+          </p>
+        )}
+        <DialogFooter className="sm:justify-end">
+          {!key ? (
+            <>
+              <DialogClose asChild>
+                <Button type="button" variant="outline">
+                  {t("actions.cancel")}
+                </Button>
+              </DialogClose>
+              <ActionButton
+                variant="destructive"
+                onClick={handleRegenerate}
+                loading={mutator.isPending}
+              >
+                {t("actions.regenerate")}
+              </ActionButton>
+            </>
+          ) : (
+            <DialogClose asChild>
+              <Button type="button" variant="outline">
+                {t("actions.close")}
+              </Button>
+            </DialogClose>
+          )}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

apps/web/lib/i18n/locales/en/translation.json

diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 065b5ed6..561c4e5a 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -80,6 +80,7 @@
     "close": "Close",
     "merge": "Merge",
     "cancel": "Cancel",
+    "regenerate": "Regenerate",
     "apply_all": "Apply All",
     "ignore": "Ignore",
     "sort": {
@@ -222,7 +223,12 @@
       "new_api_key": "New API Key",
       "new_api_key_desc": "Give your API key a unique name",
       "key_success": "Key was successfully created",
-      "key_success_please_copy": "Please copy the key and store it somewhere safe. Once you close the dialog, you won't be able to access it again."
+      "key_success_please_copy": "Please copy the key and store it somewhere safe. Once you close the dialog, you won't be able to access it again.",
+      "regenerate_api_key": "Regenerate API Key",
+      "key_regenerated": "Key was successfully regenerated",
+      "key_regenerated_please_copy": "Please copy the new key and store it somewhere safe. The old key has been revoked and will no longer work.",
+      "regenerate_warning": "Are you sure you want to regenerate the API key \"{{name}}\"?",
+      "regenerate_confirmation": "This will revoke the current key and generate a new one. Any applications using the current key will stop working."
     },
     "broken_links": {
       "broken_links": "Broken Links",

packages/trpc/auth.ts

diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts
index 01966b9e..d252bebb 100644
--- a/packages/trpc/auth.ts
+++ b/packages/trpc/auth.ts
@@ -1,5 +1,6 @@
 import { createHash, randomBytes } from "crypto";
 import * as bcrypt from "bcryptjs";
+import { and, eq } from "drizzle-orm";
 
 import { apiKeys } from "@karakeep/db/schema";
 import serverConfig from "@karakeep/shared/config";
@@ -10,21 +11,50 @@ const BCRYPT_SALT_ROUNDS = 10;
 const API_KEY_PREFIX_V1 = "ak1";
 const API_KEY_PREFIX_V2 = "ak2";
 
+function generateApiKeySecret() {
+  const secret = randomBytes(16).toString("hex");
+  return {
+    keyId: randomBytes(10).toString("hex"),
+    secret,
+    secretHash: createHash("sha256").update(secret).digest("base64"),
+  };
+}
+
 export function generatePasswordSalt() {
   return randomBytes(32).toString("hex");
 }
 
+export async function regenerateApiKey(
+  id: string,
+  userId: string,
+  database: Context["db"],
+) {
+  const { keyId, secret, secretHash } = generateApiKeySecret();
+
+  const plain = `${API_KEY_PREFIX_V2}_${keyId}_${secret}`;
+
+  const res = await database
+    .update(apiKeys)
+    .set({
+      keyId: keyId,
+      keyHash: secretHash,
+    })
+    .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
+
+  if (res.changes == 0) {
+    throw new Error("Failed to regenerate API key");
+  }
+  return plain;
+}
+
 export async function generateApiKey(
   name: string,
   userId: string,
   database: Context["db"],
 ) {
-  const id = randomBytes(10).toString("hex");
-  const secret = randomBytes(16).toString("hex");
-
-  const secretHash = createHash("sha256").update(secret).digest("base64");
+  const { keyId, secret, secretHash } = generateApiKeySecret();
 
-  const plain = `${API_KEY_PREFIX_V2}_${id}_${secret}`;
+  const plain = `${API_KEY_PREFIX_V2}_${keyId}_${secret}`;
 
   const key = (
     await database
@@ -32,7 +62,7 @@ export async function generateApiKey(
       .values({
         name: name,
         userId: userId,
-        keyId: id,
+        keyId,
         keyHash: secretHash,
       })
       .returning()

packages/trpc/routers/apiKeys.test.ts

diff --git a/packages/trpc/routers/apiKeys.test.ts b/packages/trpc/routers/apiKeys.test.ts
index b3e57db3..1fd2159a 100644
--- a/packages/trpc/routers/apiKeys.test.ts
+++ b/packages/trpc/routers/apiKeys.test.ts
@@ -141,6 +141,37 @@ describe("API Keys Routes", () => {
       );
     });
   });
+  describe("regenerate", () => {
+    test<CustomTestContext>("revokes API key successfully", async ({
+      unauthedAPICaller,
+      db,
+    }) => {
+      const user = await unauthedAPICaller.users.create({
+        name: "Test User",
+        email: "test@test.com",
+        password: "password123",
+        confirmPassword: "password123",
+      });
+
+      const api = getApiCaller(db, user.id, user.email).apiKeys;
+
+      const firstKey = await api.create({ name: "Test Key" });
+      const regeneratedKey = await api.regenerate({ id: firstKey.id });
+
+      // Validate the new key
+      const validationResult = await unauthedAPICaller.apiKeys.validate({
+        apiKey: regeneratedKey.key,
+      });
+      expect(validationResult.success).toBe(true);
+
+      // Validate the old key is revoked
+      await expect(() =>
+        unauthedAPICaller.apiKeys.validate({
+          apiKey: firstKey.key,
+        }),
+      ).rejects.toThrow();
+    });
+  });
 
   describe("revoke", () => {
     test<CustomTestContext>("revokes API key successfully", async ({

packages/trpc/routers/apiKeys.ts

diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
index dc3a3527..93b7d9ec 100644
--- a/packages/trpc/routers/apiKeys.ts
+++ b/packages/trpc/routers/apiKeys.ts
@@ -5,7 +5,12 @@ import { z } from "zod";
 import { apiKeys } from "@karakeep/db/schema";
 import serverConfig from "@karakeep/shared/config";
 
-import { authenticateApiKey, generateApiKey, validatePassword } from "../auth";
+import {
+  authenticateApiKey,
+  generateApiKey,
+  regenerateApiKey,
+  validatePassword,
+} from "../auth";
 import {
   authedProcedure,
   createRateLimitMiddleware,
@@ -31,6 +36,33 @@ export const apiKeysAppRouter = router({
     .mutation(async ({ input, ctx }) => {
       return await generateApiKey(input.name, ctx.user.id, ctx.db);
     }),
+  regenerate: authedProcedure
+    .input(
+      z.object({
+        id: z.string(),
+      }),
+    )
+    .output(zApiKeySchema)
+    .mutation(async ({ input, ctx }) => {
+      // Find the existing API key to get its name
+      const existingKey = await ctx.db.query.apiKeys.findFirst({
+        where: and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)),
+      });
+
+      if (!existingKey) {
+        throw new TRPCError({
+          message: "API key not found",
+          code: "NOT_FOUND",
+        });
+      }
+
+      return {
+        id: existingKey.id,
+        name: existingKey.name,
+        createdAt: existingKey.createdAt,
+        key: await regenerateApiKey(existingKey.id, ctx.user.id, ctx.db),
+      };
+    }),
   revoke: authedProcedure
     .input(
       z.object({