diff --git a/bun.lockb b/bun.lockb index dde4a40..71fcfc9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4efefb4..0545309 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,7 +10,7 @@ services: - POSTGRES_PASSWORD=password - POSTGRES_DB=formbase ports: - - "54321:5432" + - "5432:5432" volumes: - formbase-db:/var/lib/postgresql/data diff --git a/drizzle/0001_sudden_crusher_hogan.sql b/drizzle/0001_sudden_crusher_hogan.sql new file mode 100644 index 0000000..92162b6 --- /dev/null +++ b/drizzle/0001_sudden_crusher_hogan.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "formbase_api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_key_user_idx" ON "formbase_api_keys" ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..e41394e --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,464 @@ +{ + "id": "a26ab547-312f-49fb-bf0d-a4ecad147909", + "prevId": "68957d80-a735-4311-a193-07eb69cfdc8a", + "version": "5", + "dialect": "pg", + "tables": { + "formbase_api_keys": { + "name": "formbase_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_user_idx": { + "name": "api_key_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "formbase_email_verification_codes": { + "name": "formbase_email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "email_verif_user_idx": { + "name": "email_verif_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "email_verif_idx": { + "name": "email_verif_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "formbase_email_verification_codes_user_id_unique": { + "name": "formbase_email_verification_codes_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + } + }, + "formbase_form_datas": { + "name": "formbase_form_datas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_idx": { + "name": "form_idx", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "form_data_created_at_idx": { + "name": "form_data_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "formbase_forms": { + "name": "formbase_forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "send_email_for_new_submissions": { + "name": "send_email_for_new_submissions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "keys": { + "name": "keys", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enable_submissions": { + "name": "enable_submissions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_retention": { + "name": "enable_retention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "form_user_idx": { + "name": "form_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "form_created_at_idx": { + "name": "form_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "formbase_password_reset_tokens": { + "name": "formbase_password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "password_reset_user_idx": { + "name": "password_reset_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "formbase_sessions": { + "name": "formbase_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "formbase_users": { + "name": "formbase_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hashed_password": { + "name": "hashed_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_current_period_end": { + "name": "stripe_current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "github_idx": { + "name": "github_idx", + "columns": [ + "github_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "formbase_users_github_id_unique": { + "name": "formbase_users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + }, + "formbase_users_email_unique": { + "name": "formbase_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 69bb091..17f8d0a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1710042143399, "tag": "0000_large_zzzax", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1710258514480, + "tag": "0001_sudden_crusher_hogan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index adee83c..8a56d78 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "data-fns": "^1.1.0", "date-fns": "^3.3.1", "drizzle-orm": "^0.29.3", + "hono": "^4.1.0", "json-to-zod": "^1.1.2", "lucia": "3.0.0", "lucide-react": "^0.335.0", diff --git a/src/app/(main)/dashboard/settings/api-keys/api-key-card.tsx b/src/app/(main)/dashboard/settings/api-keys/api-key-card.tsx new file mode 100644 index 0000000..0ac2fcc --- /dev/null +++ b/src/app/(main)/dashboard/settings/api-keys/api-key-card.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import type { RouterOutputs } from "~/trpc/shared"; + +import { DeleteAPIKeyModal } from "./delete-api-key-modal"; + +type ApiKeysProps = { + apiKey: RouterOutputs["apiKeys"]["getUserKeys"]; +}; + +export function ApiKeyCard({ apiKey }: ApiKeysProps) { + return ( +
+ + +
+ {apiKey!.name} + +
+
+ + +

+ Key + + {apiKey!.id.slice(0, 6)}... + +

+

+ Created + + {new Date(apiKey!.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + +

+
+
+ + Read more about our APIs in our{" "} + + docs + {" "} + +
+ ); +} diff --git a/src/app/(main)/dashboard/settings/api-keys/api-keys-form.tsx b/src/app/(main)/dashboard/settings/api-keys/api-keys-form.tsx new file mode 100644 index 0000000..6e9e3eb --- /dev/null +++ b/src/app/(main)/dashboard/settings/api-keys/api-keys-form.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { KeyRound } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { CopyButton } from "~/components/copy-button"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { api } from "~/trpc/react"; + +const apiKeysFormSchema = z.object({ + name: z.string(), +}); + +type ApiKeysFormValues = z.infer; + +export function ApiKeysForm() { + const [apiKey, setApiKey] = useState(null); + + const form = useForm({ + resolver: zodResolver(apiKeysFormSchema), + mode: "onChange", + }); + + const router = useRouter(); + + const { mutateAsync: createApiKey, isLoading: isCreatingApiKey } = + api.apiKeys.create.useMutation({ + onSuccess: () => { + toast.success("Your API Key has been created", { + icon: , + }); + }, + }); + + const handleAPiKeyCreation = async (data: ApiKeysFormValues) => { + const apiKey = await createApiKey(data); + + setApiKey(apiKey); + + form.reset(); + }; + + return ( + <> +
+ + ( + + Name + + + + + + )} + /> + + + + + + {apiKey && ( + { + setApiKey(null); + router.refresh(); + }} + > + + + API Key Created + + Please copy your API key. + You won't be able to see it again. + + +
+

{apiKey}

+ +
+
+
+ )} + + ); +} diff --git a/src/app/(main)/dashboard/settings/api-keys/delete-api-key-modal.tsx b/src/app/(main)/dashboard/settings/api-keys/delete-api-key-modal.tsx new file mode 100644 index 0000000..1460ef8 --- /dev/null +++ b/src/app/(main)/dashboard/settings/api-keys/delete-api-key-modal.tsx @@ -0,0 +1,82 @@ +import { TrashIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { api } from "~/trpc/react"; + +type DeleteApiKeyDialogProps = { + apiKeyId: string; + onSuccessfulDelete?: () => void; +}; + +export function DeleteAPIKeyModal({ + apiKeyId, + onSuccessfulDelete, +}: DeleteApiKeyDialogProps) { + const [open, setOpen] = useState(false); + + const router = useRouter(); + const { mutateAsync: deleteApiKey, isLoading: isDeletingApiKey } = + api.apiKeys.delete.useMutation(); + + const handleDelete = async () => { + await deleteApiKey( + { + id: apiKeyId, + }, + { + onSuccess: () => { + router.refresh(); + toast.success("Your API Key has been deleted", { + icon: , + }); + onSuccessfulDelete?.(); + }, + }, + ); + }; + + return ( + + + + + + + Delete Api Key + + Are you sure you want to delete this API Key? This action cannot be + undone. + + +
+ + +
+
+
+ ); +} diff --git a/src/app/(main)/dashboard/settings/api-keys/page.tsx b/src/app/(main)/dashboard/settings/api-keys/page.tsx new file mode 100644 index 0000000..4e79518 --- /dev/null +++ b/src/app/(main)/dashboard/settings/api-keys/page.tsx @@ -0,0 +1,38 @@ +import { type Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { Separator } from "~/components/ui/separator"; +import { env } from "~/env"; +import { validateRequest } from "~/lib/auth/validate-request"; +import { api } from "~/trpc/server"; + +import { ApiKeyCard } from "./api-key-card"; +import { ApiKeysForm } from "./api-keys-form"; + +export const metadata: Metadata = { + metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), + title: "API Keys", +}; + +export default async function SettingsPage() { + const { user } = await validateRequest(); + + if (!user) { + redirect("/signin"); + } + + const userKeys = await api.apiKeys.getUserKeys.query(); + + return ( +
+
+

API Keys

+

+ Generate and manage your API keys +

+
+ +
{userKeys ? : }
+
+ ); +} diff --git a/src/app/(main)/dashboard/settings/layout.tsx b/src/app/(main)/dashboard/settings/layout.tsx index c64385b..d3dfc10 100644 --- a/src/app/(main)/dashboard/settings/layout.tsx +++ b/src/app/(main)/dashboard/settings/layout.tsx @@ -12,6 +12,10 @@ const sidebarNavItems = [ title: "Profile", href: "/dashboard/settings", }, + { + title: "API Keys", + href: "/dashboard/settings/api-keys", + }, ]; interface SettingsLayoutProps { diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts new file mode 100644 index 0000000..85a09d5 --- /dev/null +++ b/src/app/api/[[...route]]/route.ts @@ -0,0 +1,37 @@ +import { Hono } from "hono"; +import { handle } from "hono/vercel"; + +import { publicFormRouter } from "./routes/forms"; +import { verifyApiKey } from "./routes/util"; + +export type Variables = { + userId: string; + apiKeyID: string; +}; + +export const app = new Hono<{ Variables: Variables }>().basePath("/api/v1"); + +app.use("/forms/*", async (c, next) => { + const authorizationKey = c.req.raw.headers.get("X-Formbase-Key"); + if (!authorizationKey) { + return c.json({ error: "Unauthorized" }, 401); + } + + const { error, result } = await verifyApiKey(authorizationKey); + + if (error) { + return c.json({ error: error.message }, 401); + } + + c.set("userId", result.ownerId); + c.set("apiKeyID", result.apiKeyID); + + await next(); +}); + +app.route("/forms", publicFormRouter); + +export const GET = handle(app); +export const POST = handle(app); +export const DELETE = handle(app); +export const PATCH = handle(app); diff --git a/src/app/api/[[...route]]/routes/forms/index.ts b/src/app/api/[[...route]]/routes/forms/index.ts new file mode 100644 index 0000000..8b79cf0 --- /dev/null +++ b/src/app/api/[[...route]]/routes/forms/index.ts @@ -0,0 +1,158 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { validator } from "hono/validator"; + +import { generateId } from "~/lib/utils/generate-id"; +import { db } from "~/server/db"; +import { forms } from "~/server/db/schema"; + +import { CreateFormInputSchema, UpdateFormInputSchema } from "./schema"; +import { type Variables } from "../../route"; + +const app = new Hono<{ Variables: Variables }>(); + +app.get("/", async (c) => { + const userId = c.get("userId"); + + const userForms = await db + .select() + .from(forms) + .where(eq(forms.userId, userId)); + + const transformedForms = userForms.map((form) => { + return { + id: form.id, + title: form.title, + description: form.description, + createdAt: form.createdAt, + }; + }); + + return c.json({ + forms: transformedForms, + }); +}); + +app.post( + "/", + validator("json", (value, c) => { + const parsed = CreateFormInputSchema.safeParse(value); + + if (!parsed.success) { + return c.json( + { + error: parsed.error.errors, + }, + 400, + ); + } + + return parsed.data; + }), + async (c) => { + const data = c.req.valid("json"); + + const id = generateId(15); + + await db.insert(forms).values({ + id, + userId: c.get("userId"), + ...data, + keys: [""], + }); + + return c.json({ + form: { + id, + ...data, + }, + }); + }, +); + +app.get("/:id", async (c) => { + const id = c.req.param("id"); + + const formDataWithSubmissions = await db.query.forms.findMany({ + where: (table, { and }) => + and(eq(table.id, id), eq(table.userId, c.get("userId"))), + orderBy: (table, { desc }) => desc(table.createdAt), + columns: { + id: true, + title: true, + description: true, + createdAt: true, + }, + with: { + formData: { + columns: { data: true, createdAt: true }, + }, + }, + }); + + return c.json({ + form: formDataWithSubmissions, + }); +}); + +app.patch( + "/:id", + validator("json", (value, c) => { + const parsed = UpdateFormInputSchema.safeParse(value); + + if (!parsed.success) { + return c.json( + { + error: parsed.error.errors, + }, + 400, + ); + } + + return parsed.data; + }), + async (c) => { + const id = c.req.param("id"); + const data = c.req.valid("json"); + + const form = await db.query.forms.findFirst({ + where: (table, { and }) => + and(eq(table.id, id), eq(table.userId, c.get("userId"))), + }); + + if (!form) { + return c.notFound(); + } + + await db + .update(forms) + .set({ + ...data, + }) + .where(eq(forms.id, id)); + + return c.json({ + form: { + ...data, + id, + }, + }); + }, +); + +app.delete("/:id", async (c) => { + const id = c.req.param("id"); + const userId = c.get("userId"); + + const form = await db.query.forms.findFirst({ + where: (table, { and }) => and(eq(table.id, id), eq(table.userId, userId)), + }); + + if (!form) return c.notFound(); + + await db.delete(forms).where(eq(forms.id, id)); + + return c.json({}); +}); + +export const publicFormRouter = app; diff --git a/src/app/api/[[...route]]/routes/forms/schema.ts b/src/app/api/[[...route]]/routes/forms/schema.ts new file mode 100644 index 0000000..b8f8b53 --- /dev/null +++ b/src/app/api/[[...route]]/routes/forms/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const CreateFormInputSchema = z.object({ + title: z.string(), + description: z.string().optional(), + enableEmailNotifications: z.boolean().default(true), + returnUrl: z.string().url().optional(), +}); + +export const UpdateFormInputSchema = CreateFormInputSchema.partial(); diff --git a/src/app/api/[[...route]]/routes/util.ts b/src/app/api/[[...route]]/routes/util.ts new file mode 100644 index 0000000..97a5f5e --- /dev/null +++ b/src/app/api/[[...route]]/routes/util.ts @@ -0,0 +1,35 @@ +import crypto from "crypto"; + +import { eq } from "drizzle-orm"; + +import { db } from "~/server/db"; +import { apiKeys } from "~/server/db/schema"; + +type VerifyApiKeyReturnType = + | { + result: { + ownerId: string; + apiKeyID: string; + }; + error?: undefined; + } + | { + error: Error; + result?: undefined; + }; + +export const verifyApiKey = async ( + key: string, +): Promise => { + const hash = crypto.createHash("sha256").update(key).digest("hex"); + + const hashedKey = await db.query.apiKeys.findFirst({ + where: eq(apiKeys.key, hash), + }); + + if (!hashedKey) { + return { error: new Error("Invalid API Key") }; + } + + return { result: { ownerId: hashedKey.userId, apiKeyID: hashedKey.id } }; +}; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 510ccf0..84b5088 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,3 +1,4 @@ +import { apiKeysRouter } from "./routers/apiKeys"; import { formRouter } from "./routers/form"; import { formDataRouter } from "./routers/formData"; import { stripeRouter } from "./routers/stripe"; @@ -9,6 +10,7 @@ export const appRouter = createTRPCRouter({ stripe: stripeRouter, form: formRouter, formData: formDataRouter, + apiKeys: apiKeysRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/apiKeys.ts b/src/server/api/routers/apiKeys.ts new file mode 100644 index 0000000..f9f5204 --- /dev/null +++ b/src/server/api/routers/apiKeys.ts @@ -0,0 +1,100 @@ +import crypto from "crypto"; + +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { apiKeys } from "~/server/db/schema"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const apiKeysRouter = createTRPCRouter({ + getUserKeys: protectedProcedure.query(async ({ ctx }) => { + return ctx.db.query.apiKeys.findFirst({ + where: (table, { eq }) => eq(table.userId, ctx.user.id), + }); + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const generatedKey = nanoid(21); + + const hash = crypto + .createHash("sha256") + .update(generatedKey) + .digest("hex"); + + await ctx.db + .insert(apiKeys) + .values({ + // store the first 6 strings of the random key as part of the id + // so we display that to the user on their api keys page + id: generatedKey.slice(0, 6) + nanoid(12), + userId: ctx.user.id, + key: hash, + name: input.name, + }) + .returning(); + + return generatedKey; + }), + + delete: protectedProcedure + .input( + z.object({ + id: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table, { eq }) => eq(table.id, input.id), + }); + + if (!apiKey) { + throw new Error("API Key not found"); + } + + if (apiKey.userId !== ctx.user.id) { + throw new Error("Unauthorized"); + } + + await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.id)); + + return true; + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table, { eq }) => eq(table.id, input.id), + }); + + if (!apiKey) { + throw new Error("API Key not found"); + } + + if (apiKey.userId !== ctx.user.id) { + throw new Error("Unauthorized"); + } + + await ctx.db + .update(apiKeys) + .set({ + name: input.name, + }) + .where(eq(apiKeys.id, input.id)); + + return true; + }), +}); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index b060e49..d1f2f56 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -132,6 +132,27 @@ export const formDataRelations = relations(formDatas, ({ one }) => ({ }), })); +export const apiKeys = createTable( + "api_keys", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + key: text("key").notNull(), + name: text("name").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => ({ + userIdx: index("api_key_user_idx").on(t.userId), + }), +); + +export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ + user: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), +})); + export type FormData = typeof formDatas.$inferSelect; export type NewFormData = typeof formDatas.$inferInsert; @@ -139,3 +160,5 @@ export type Form = typeof forms.$inferSelect; export type NewForm = typeof forms.$inferInsert; export type Session = typeof sessions.$inferSelect; + +export type ApiKey = typeof apiKeys.$inferSelect;