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 (
+ <>
+
+
+
+ {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 (
+
+ );
+}
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
+
+
+
+
+
+ );
+}
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;