aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/settings/AddApiKey.tsx25
-rw-r--r--apps/web/components/settings/ApiKeySettings.tsx6
-rw-r--r--apps/web/components/settings/ApiKeySuccess.tsx24
-rw-r--r--apps/web/components/settings/RegenerateApiKey.tsx118
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json8
-rw-r--r--packages/trpc/auth.ts42
-rw-r--r--packages/trpc/routers/apiKeys.test.ts31
-rw-r--r--packages/trpc/routers/apiKeys.ts34
8 files changed, 259 insertions, 29 deletions
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} />
)}
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>
))}
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>
+ );
+}
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>
+ );
+}
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",
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()
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 ({
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({