aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql16
-rw-r--r--packages/db/prisma/schema.prisma13
-rw-r--r--packages/web/app/dashboard/components/Sidebar.tsx15
-rw-r--r--packages/web/app/dashboard/settings/components/AddApiKey.tsx148
-rw-r--r--packages/web/app/dashboard/settings/components/ApiKeySettings.tsx49
-rw-r--r--packages/web/app/dashboard/settings/components/DeleteApiKey.tsx65
-rw-r--r--packages/web/app/dashboard/settings/page.tsx10
-rw-r--r--packages/web/components/ui/dialog.tsx122
-rw-r--r--packages/web/components/ui/table.tsx117
-rw-r--r--packages/web/package.json3
-rw-r--r--packages/web/server/api/routers/_app.ts2
-rw-r--r--packages/web/server/api/routers/apiKeys.ts67
-rw-r--r--packages/web/server/auth.ts75
-rw-r--r--packages/workers/openai.ts8
14 files changed, 707 insertions, 3 deletions
diff --git a/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql
new file mode 100644
index 00000000..c39bf511
--- /dev/null
+++ b/packages/db/prisma/migrations/20240211184744_add_api_key/migration.sql
@@ -0,0 +1,16 @@
+-- CreateTable
+CREATE TABLE "ApiKey" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "keyId" TEXT NOT NULL,
+ "keyHash" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_keyId_key" ON "ApiKey"("keyId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 623a9f13..5c575c97 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -47,6 +47,7 @@ model User {
sessions Session[]
tags BookmarkTags[]
bookmarks Bookmark[]
+ apiKeys ApiKey[]
}
model VerificationToken {
@@ -57,6 +58,18 @@ model VerificationToken {
@@unique([identifier, token])
}
+model ApiKey {
+ id String @id @default(cuid())
+ name String
+ createdAt DateTime @default(now())
+ keyId String @unique
+ keyHash String
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([name, userId])
+}
+
model Bookmark {
id String @id @default(cuid())
createdAt DateTime @default(now())
diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx
index 44892e81..d2ec14a6 100644
--- a/packages/web/app/dashboard/components/Sidebar.tsx
+++ b/packages/web/app/dashboard/components/Sidebar.tsx
@@ -1,5 +1,13 @@
import { Button } from "@/components/ui/button";
-import { Archive, MoreHorizontal, Star, Tag, Home, Brain } from "lucide-react";
+import {
+ Archive,
+ MoreHorizontal,
+ Star,
+ Tag,
+ Home,
+ Brain,
+ Settings,
+} from "lucide-react";
import { redirect } from "next/navigation";
import SidebarItem from "./SidebarItem";
import { getServerAuthSession } from "@/server/auth";
@@ -35,6 +43,11 @@ export default async function Sidebar() {
path="/dashboard/bookmarks/archive"
/>
<SidebarItem logo={<Tag />} name="Tags" path="#" />
+ <SidebarItem
+ logo={<Settings />}
+ name="Settings"
+ path="/dashboard/settings"
+ />
</ul>
</div>
<div className="mt-auto flex justify-between">
diff --git a/packages/web/app/dashboard/settings/components/AddApiKey.tsx b/packages/web/app/dashboard/settings/components/AddApiKey.tsx
new file mode 100644
index 00000000..f4f2894c
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/AddApiKey.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { z } from "zod";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm, SubmitErrorHandler } from "react-hook-form";
+import { toast } from "@/components/ui/use-toast";
+import { api } from "@/lib/trpc";
+import { useState } from "react";
+import { Copy } from "lucide-react";
+
+function ApiKeySuccess({ apiKey }: { apiKey: string }) {
+ return (
+ <div>
+ <div className="py-4">
+ Note: please copy the key and store it somewhere safe. Once you close
+ the dialog, you won&apos;t be able to access it again.
+ </div>
+ <div className="flex space-x-2 pt-2">
+ <Input value={apiKey} readOnly />
+ <Button onClick={() => navigator.clipboard.writeText(apiKey)}>
+ <Copy className="size-4" />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) {
+ const formSchema = z.object({
+ name: z.string(),
+ });
+ const router = useRouter();
+
+ const form = useForm<z.infer<typeof formSchema>>({
+ resolver: zodResolver(formSchema),
+ });
+
+ async function onSubmit(value: z.infer<typeof formSchema>) {
+ try {
+ const resp = await api.apiKeys.create.mutate({ name: value.name });
+ onSuccess(resp.key);
+ } catch (e) {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ return;
+ }
+ router.refresh();
+ }
+
+ const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
+ toast({
+ description: Object.values(errors)
+ .map((v) => v.message)
+ .join("\n"),
+ variant: "destructive",
+ });
+ };
+
+ return (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit, onError)}
+ className="flex w-full space-x-3 space-y-8 pt-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => {
+ return (
+ <FormItem className="flex-1">
+ <FormLabel>Name</FormLabel>
+ <FormControl>
+ <Input type="text" placeholder="Name" {...field} />
+ </FormControl>
+ <FormDescription>
+ Give your API key a unique name
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ <Button className="h-full" type="submit">
+ Create
+ </Button>
+ </form>
+ </Form>
+ );
+}
+
+export default function AddApiKey() {
+ const [key, setKey] = useState<string | undefined>(undefined);
+ const [dialogOpen, setDialogOpen] = useState<boolean>(false);
+ return (
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+ <DialogTrigger asChild>
+ <Button>New API Key</Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>
+ {key ? "Key was successfully created" : "Create API key"}
+ </DialogTitle>
+ <DialogDescription>
+ {key ? (
+ <ApiKeySuccess apiKey={key} />
+ ) : (
+ <AddApiKeyForm onSuccess={setKey} />
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setKey(undefined)}
+ >
+ Close
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx
new file mode 100644
index 00000000..1598f25f
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/ApiKeySettings.tsx
@@ -0,0 +1,49 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { api } from "@/server/api/client";
+import DeleteApiKey from "./DeleteApiKey";
+import AddApiKey from "./AddApiKey";
+
+export default async function ApiKeys() {
+ const keys = await api.apiKeys.list();
+ return (
+ <div className="pt-4">
+ <span className="text-xl">API Keys</span>
+ <hr className="my-2" />
+ <div className="flex flex-col space-y-3">
+ <div className="flex flex-1 justify-end">
+ <AddApiKey />
+ </div>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Name</TableHead>
+ <TableHead>Key</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Action</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {keys.keys.map((k) => (
+ <TableRow key={k.id}>
+ <TableCell>{k.name}</TableCell>
+ <TableCell>**_{k.keyId}_**</TableCell>
+ <TableCell>{k.createdAt.toLocaleString()}</TableCell>
+ <TableCell>
+ <DeleteApiKey name={k.name} id={k.id} />
+ </TableCell>
+ </TableRow>
+ ))}
+ <TableRow></TableRow>
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx
new file mode 100644
index 00000000..715b7a2c
--- /dev/null
+++ b/packages/web/app/dashboard/settings/components/DeleteApiKey.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Trash } from "lucide-react";
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { api } from "@/lib/trpc";
+import { useRouter } from "next/navigation";
+import { toast } from "@/components/ui/use-toast";
+
+export default function DeleteApiKey({
+ name,
+ id,
+}: {
+ name: string;
+ id: string;
+}) {
+ const router = useRouter();
+ const deleteKey = async () => {
+ await api.apiKeys.revoke.mutate({ id });
+ toast({
+ description: "Key was successfully deleted",
+ });
+ router.refresh();
+ };
+ return (
+ <Dialog>
+ <DialogTrigger asChild>
+ <Button variant="destructive">
+ <Trash className="size-5" />
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete API Key</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete the API key &quot;{name}&quot;? Any
+ service using this API key will lose access.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ Close
+ </Button>
+ </DialogClose>
+ <DialogClose asChild>
+ <Button type="button" variant="destructive" onClick={deleteKey}>
+ Delete
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx
new file mode 100644
index 00000000..e8799583
--- /dev/null
+++ b/packages/web/app/dashboard/settings/page.tsx
@@ -0,0 +1,10 @@
+import { Button } from "@/components/ui/button";
+import ApiKeySettings from "./components/ApiKeySettings";
+export default async function Settings() {
+ return (
+ <div className="m-4 flex flex-col space-y-2 rounded-md border bg-white p-4">
+ <p className="text-2xl">Settings</p>
+ <ApiKeySettings />
+ </div>
+ );
+}
diff --git a/packages/web/components/ui/dialog.tsx b/packages/web/components/ui/dialog.tsx
new file mode 100644
index 00000000..8fe3fe35
--- /dev/null
+++ b/packages/web/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
+ <X className="size-4" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-1.5 text-center sm:text-left",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg font-semibold leading-none tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/packages/web/components/ui/table.tsx b/packages/web/components/ui/table.tsx
new file mode 100644
index 00000000..0fa9288e
--- /dev/null
+++ b/packages/web/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+ <div className="relative w-full overflow-auto">
+ <table
+ ref={ref}
+ className={cn("w-full caption-bottom text-sm", className)}
+ {...props}
+ />
+ </div>
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tbody
+ ref={ref}
+ className={cn("[&_tr:last-child]:border-0", className)}
+ {...props}
+ />
+));
+TableBody.displayName = "TableBody";
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+ <tfoot
+ ref={ref}
+ className={cn(
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+ <tr
+ ref={ref}
+ className={cn(
+ "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className,
+ )}
+ {...props}
+ />
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <th
+ ref={ref}
+ className={cn(
+ "text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+ <td
+ ref={ref}
+ className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+ <caption
+ ref={ref}
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
+ {...props}
+ />
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/packages/web/package.json b/packages/web/package.json
index 0601e4f4..d66a3fe7 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -14,6 +14,7 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@next/eslint-plugin-next": "^14.1.0",
"@prisma/client": "^5.9.1",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
@@ -22,6 +23,7 @@
"@trpc/client": "11.0.0-next-beta.274",
"@trpc/next": "11.0.0-next-beta.274",
"@trpc/server": "11.0.0-next-beta.274",
+ "bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"install": "^0.13.0",
@@ -38,6 +40,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
+ "@types/bcrypt": "^5.0.2",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts
index a4f9c629..2097b47d 100644
--- a/packages/web/server/api/routers/_app.ts
+++ b/packages/web/server/api/routers/_app.ts
@@ -1,7 +1,9 @@
import { router } from "../trpc";
+import { apiKeysAppRouter } from "./apiKeys";
import { bookmarksAppRouter } from "./bookmarks";
export const appRouter = router({
bookmarks: bookmarksAppRouter,
+ apiKeys: apiKeysAppRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/web/server/api/routers/apiKeys.ts b/packages/web/server/api/routers/apiKeys.ts
new file mode 100644
index 00000000..b681d43f
--- /dev/null
+++ b/packages/web/server/api/routers/apiKeys.ts
@@ -0,0 +1,67 @@
+import { generateApiKey } from "@/server/auth";
+import { authedProcedure, router } from "../trpc";
+import { prisma } from "@remember/db";
+import { z } from "zod";
+
+export const apiKeysAppRouter = router({
+ create: authedProcedure
+ .input(
+ z.object({
+ name: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ key: z.string(),
+ createdAt: z.date(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ return await generateApiKey(input.name, ctx.user.id);
+ }),
+ revoke: authedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .output(z.object({}))
+ .mutation(async ({ input, ctx }) => {
+ const resp = await prisma.apiKey.delete({
+ where: {
+ id: input.id,
+ userId: ctx.user.id,
+ },
+ });
+ return resp;
+ }),
+ list: authedProcedure
+ .output(
+ z.object({
+ keys: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.date(),
+ keyId: z.string(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const resp = await prisma.apiKey.findMany({
+ where: {
+ userId: ctx.user.id,
+ },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ keyId: true,
+ },
+ });
+ return { keys: resp };
+ }),
+});
diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts
index 05d3d296..f78fa8cf 100644
--- a/packages/web/server/auth.ts
+++ b/packages/web/server/auth.ts
@@ -4,6 +4,9 @@ import AuthentikProvider from "next-auth/providers/authentik";
import serverConfig from "@/server/config";
import { prisma } from "@remember/db";
import { DefaultSession } from "next-auth";
+import * as bcrypt from "bcrypt";
+
+import { randomBytes } from "crypto";
declare module "next-auth" {
/**
@@ -37,3 +40,75 @@ export const authOptions: NextAuthOptions = {
export const authHandler = NextAuth(authOptions);
export const getServerAuthSession = () => getServerSession(authOptions);
+
+// API Keys
+
+const BCRYPT_SALT_ROUNDS = 10;
+const API_KEY_PREFIX = "ak1";
+
+export async function generateApiKey(name: string, userId: string) {
+ const id = randomBytes(10).toString("hex");
+ const secret = randomBytes(10).toString("hex");
+ const secretHash = await bcrypt.hash(secret, BCRYPT_SALT_ROUNDS);
+
+ const plain = `${API_KEY_PREFIX}_${id}_${secret}`;
+
+ const key = await prisma.apiKey.create({
+ data: {
+ name: name,
+ userId: userId,
+ keyId: id,
+ keyHash: secretHash,
+ },
+ });
+
+ return {
+ id: key.id,
+ name: key.name,
+ createdAt: key.createdAt,
+ key: plain,
+ };
+}
+
+function parseApiKey(plain: string) {
+ const parts = plain.split("_");
+ if (parts.length != 3) {
+ throw new Error(
+ `Malformd API key. API keys should have 3 segments, found ${parts.length} instead.`,
+ );
+ }
+ if (parts[0] !== API_KEY_PREFIX) {
+ throw new Error(`Malformd API key. Got unexpected key prefix.`);
+ }
+ return {
+ keyId: parts[1],
+ keySecret: parts[2],
+ };
+}
+
+export async function authenticateApiKey(key: string) {
+ const { keyId, keySecret } = parseApiKey(key);
+ const apiKey = await prisma.apiKey.findUnique({
+ where: {
+ keyId,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ if (!apiKey) {
+ throw new Error("API key not found");
+ }
+
+ const hash = apiKey.keyHash;
+
+ const validation = await bcrypt.compare(keySecret, hash);
+ if (!validation) {
+ throw new Error("Invalid API Key");
+ }
+
+ return {
+ user: apiKey.user,
+ };
+}
diff --git a/packages/workers/openai.ts b/packages/workers/openai.ts
index 999f2827..1adedeba 100644
--- a/packages/workers/openai.ts
+++ b/packages/workers/openai.ts
@@ -1,6 +1,11 @@
import { prisma, BookmarkedLink } from "@remember/db";
import logger from "@remember/shared/logger";
-import { OpenAIQueue, ZOpenAIRequest, queueConnectionDetails, zOpenAIRequestSchema } from "@remember/shared/queues";
+import {
+ OpenAIQueue,
+ ZOpenAIRequest,
+ queueConnectionDetails,
+ zOpenAIRequestSchema,
+} from "@remember/shared/queues";
import { Job } from "bullmq";
import OpenAI from "openai";
import { z } from "zod";
@@ -9,7 +14,6 @@ import { Worker } from "bullmq";
const openAIResponseSchema = z.object({
tags: z.array(z.string()),
});
-
export class OpenAiWorker {
static async build() {