From 1d00aa78b413fea833c693a5d5f287206ef9368a Mon Sep 17 00:00:00 2001 From: Henry Date: Sat, 31 May 2025 23:59:48 +0100 Subject: [PATCH] feat: create and delete api keys --- apps/web/src/components/Input.tsx | 25 +++++++- apps/web/src/pages/api/v1/[...trpc].ts | 5 +- .../settings/components/CreateAPIKeyForm.tsx | 60 +++++++++++++++++++ apps/web/src/views/settings/index.tsx | 19 +++++- packages/api/src/routers/user.ts | 14 ++++- packages/api/src/trpc.ts | 21 +++---- packages/auth/src/clients.ts | 4 +- packages/db/src/repository/user.repo.ts | 15 ++++- packages/db/src/schema/auth.ts | 10 +++- packages/db/src/schema/index.ts | 2 + packages/db/src/schema/users.ts | 2 + 11 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/views/settings/components/CreateAPIKeyForm.tsx diff --git a/apps/web/src/components/Input.tsx b/apps/web/src/components/Input.tsx index 911dbabd..d2712b2e 100644 --- a/apps/web/src/components/Input.tsx +++ b/apps/web/src/components/Input.tsx @@ -1,5 +1,6 @@ -import React, { forwardRef } from "react"; +import React, { forwardRef, useState } from "react"; import ContentEditable from "react-contenteditable"; +import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; interface InputProps extends React.InputHTMLAttributes { @@ -14,6 +15,7 @@ interface InputProps extends React.InputHTMLAttributes { e: React.ChangeEvent, ) => void; onKeyDown?: (e: React.KeyboardEvent) => void; + type?: string; } const Input = forwardRef( @@ -27,10 +29,13 @@ const Input = forwardRef( onKeyDown, iconRight, className, + type = "text", ...props }, ref, ) => { + const [showPassword, setShowPassword] = useState(false); + if (contentEditable) { return ( ( )} ( )} {...props} /> - {iconRight && ( + {type === "password" && ( + + )} + {iconRight && type !== "password" && (
{iconRight}
diff --git a/apps/web/src/pages/api/v1/[...trpc].ts b/apps/web/src/pages/api/v1/[...trpc].ts index 601ee6cc..4fe7ac6f 100644 --- a/apps/web/src/pages/api/v1/[...trpc].ts +++ b/apps/web/src/pages/api/v1/[...trpc].ts @@ -1,4 +1,4 @@ -import { type NextApiRequest, type NextApiResponse } from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; import cors from "nextjs-cors"; import { createOpenApiNextHandler } from "trpc-to-openapi"; @@ -16,12 +16,11 @@ export default async function handler( const openApiHandler = createOpenApiNextHandler({ router: appRouter, createContext: createRESTContext, - responseMeta: () => ({ headers: {} }), onError: env.NODE_ENV === "development" ? ({ path, error }) => { console.error( - `❌ tRPC failed on ${path ?? ""}: ${error.message}`, + `❌ REST failed on ${path ?? ""}: ${error.message}`, ); } : undefined, diff --git a/apps/web/src/views/settings/components/CreateAPIKeyForm.tsx b/apps/web/src/views/settings/components/CreateAPIKeyForm.tsx new file mode 100644 index 00000000..15b95da1 --- /dev/null +++ b/apps/web/src/views/settings/components/CreateAPIKeyForm.tsx @@ -0,0 +1,60 @@ +import { authClient } from "@kan/auth"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; + +const CreateAPIKeyForm = ({ + apiKey, + refetchUser, +}: { + apiKey: + | { + id: number; + prefix: string | null; + key: string; + } + | null + | undefined; + refetchUser: () => void; +}) => { + console.log({ apiKey }); + const handleCreateAPIKey = async () => { + await authClient.apiKey.create(); + + refetchUser(); + }; + + const handleRevokeAPIKey = async () => { + if (!apiKey) return; + await authClient.apiKey.delete({ + keyId: apiKey.id.toString(), + }); + + refetchUser(); + }; + + return ( +
+ {apiKey ? ( +
+
+ +
+
+ +
+
+ ) : ( + + )} +
+ ); +}; + +export default CreateAPIKeyForm; diff --git a/apps/web/src/views/settings/index.tsx b/apps/web/src/views/settings/index.tsx index 29bc847e..25b1575f 100644 --- a/apps/web/src/views/settings/index.tsx +++ b/apps/web/src/views/settings/index.tsx @@ -8,6 +8,7 @@ import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; import Avatar from "./components/Avatar"; +import CreateAPIKeyForm from "./components/CreateAPIKeyForm"; import { CustomURLConfirmation } from "./components/CustomURLConfirmation"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm"; @@ -18,9 +19,12 @@ import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; export default function SettingsPage() { const { modalContentType, openModal } = useModal(); const { workspace } = useWorkspace(); + const utils = api.useUtils(); const { data } = api.user.getUser.useQuery(); + const refetchUser = () => utils.user.getUser.refetch(); + const handleOpenBillingPortal = async () => { try { const response = await fetch("/api/stripe/create_billing_session", { @@ -91,7 +95,7 @@ export default function SettingsPage() { /> - {process.env.NEXT_PUBLIC_KAN_ENV !== "cloud" && ( + {process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && (

Billing @@ -109,6 +113,19 @@ export default function SettingsPage() {

)} +
+

+ API keys +

+

+ View and manage your API keys. +

+ +
+

Delete workspace diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index 2052cf91..88b4235c 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -26,6 +26,13 @@ export const userRouter = createTRPCRouter({ name: z.string().nullable(), image: z.string().nullable(), stripeCustomerId: z.string().nullable(), + apiKey: z + .object({ + id: z.number(), + prefix: z.string().nullable(), + key: z.string(), + }) + .nullable(), }), ) .query(async ({ ctx }) => { @@ -46,7 +53,12 @@ export const userRouter = createTRPCRouter({ }); } - return result; + const apiKey = result.apiKeys[0]; + + return { + ...result, + apiKey: apiKey ?? null, + }; }), update: protectedProcedure .meta({ diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 7f84807c..a09daa34 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -57,23 +57,20 @@ export const createNextApiContext = async (req: NextApiRequest) => { }; export const createRESTContext = async ({ req }: CreateNextContextOptions) => { - const authHeader = req.headers.authorization; - const accessToken = authHeader?.startsWith("Bearer ") - ? authHeader.substring(7) - : null; - const db = createDrizzleClient(); const auth = initAuth(db); - if (!accessToken) { - return createInnerTRPCContext({ db, user: null }); + let session; + try { + session = await auth.api.getSession({ + // @ts-expect-error + headers: new Headers(req.headers), + }); + } catch (error) { + console.error("Error getting session, ", error); + throw error; } - const session = await auth.api.getSession({ - // @ts-expect-error - headers: new Headers(req.headers), - }); - return createInnerTRPCContext({ db, user: session?.user }); }; diff --git a/packages/auth/src/clients.ts b/packages/auth/src/clients.ts index d0f471c0..39e8124b 100644 --- a/packages/auth/src/clients.ts +++ b/packages/auth/src/clients.ts @@ -1,6 +1,6 @@ -import { magicLinkClient } from "better-auth/client/plugins"; +import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - plugins: [magicLinkClient()], + plugins: [magicLinkClient(), apiKeyClient()], }); diff --git a/packages/db/src/repository/user.repo.ts b/packages/db/src/repository/user.repo.ts index 1c2d8f1f..df104cd6 100644 --- a/packages/db/src/repository/user.repo.ts +++ b/packages/db/src/repository/user.repo.ts @@ -1,8 +1,8 @@ -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import type { dbClient } from "@kan/db/client"; -import { users } from "@kan/db/schema"; +import { apiKey, users } from "@kan/db/schema"; export const getById = async (db: dbClient, userId: string) => { return await db.query.users.findFirst({ @@ -13,6 +13,17 @@ export const getById = async (db: dbClient, userId: string) => { image: true, stripeCustomerId: true, }, + with: { + apiKeys: { + columns: { + id: true, + prefix: true, + key: true, + }, + orderBy: desc(apiKey.createdAt), + limit: 1, + }, + }, where: eq(users.id, userId), }); }; diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index ba0aa246..f911a2c4 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -1,3 +1,4 @@ +import { relations } from "drizzle-orm"; import { bigserial, boolean, @@ -50,7 +51,7 @@ export const verification = pgTable("verification", { updatedAt: timestamp("updatedAt"), }).enableRLS(); -export const apiKey = pgTable("apiKey", { +export const apiKey = pgTable("apikey", { id: bigserial("id", { mode: "number" }).primaryKey(), name: text("name"), start: text("start"), @@ -75,3 +76,10 @@ export const apiKey = pgTable("apiKey", { permissions: text("permissions"), metadata: text("metadata"), }).enableRLS(); + +export const apiKeyRelations = relations(apiKey, ({ one }) => ({ + user: one(users, { + fields: [apiKey.userId], + references: [users.id], + }), +})); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b94e20c1..5568a3d3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -8,3 +8,5 @@ export * from "./labels"; export * from "./lists"; export * from "./users"; export * from "./workspaces"; + +export { apiKey as apikey } from "./auth"; diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts index b73d160a..00e91d88 100644 --- a/packages/db/src/schema/users.ts +++ b/packages/db/src/schema/users.ts @@ -7,6 +7,7 @@ import { varchar, } from "drizzle-orm/pg-core"; +import { apiKey } from "./auth"; import { boards } from "./boards"; import { cards } from "./cards"; import { imports } from "./imports"; @@ -33,6 +34,7 @@ export const usersRelations = relations(users, ({ many }) => ({ imports: many(imports), lists: many(lists), workspaces: many(workspaces), + apiKeys: many(apiKey), })); export const usersToWorkspacesRelations = relations(