feat: create and delete api keys

This commit is contained in:
Henry
2025-05-31 23:59:48 +01:00
parent e61dd4e27e
commit 1d00aa78b4
11 changed files with 153 additions and 24 deletions

View File

@@ -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<HTMLInputElement> {
@@ -14,6 +15,7 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
type?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
@@ -27,10 +29,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
onKeyDown,
iconRight,
className,
type = "text",
...props
},
ref,
) => {
const [showPassword, setShowPassword] = useState(false);
if (contentEditable) {
return (
<ContentEditable
@@ -56,7 +61,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)}
<input
ref={ref}
value={value}
onChange={onChange}
type={type === "password" && showPassword ? "text" : type}
className={twMerge(
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
prefix && "rounded-l-none",
@@ -64,7 +71,21 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)}
{...props}
/>
{iconRight && (
{type === "password" && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
tabIndex={-1}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<HiOutlineEyeSlash className="h-4 w-4" />
) : (
<HiOutlineEye className="h-4 w-4" />
)}
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{iconRight}
</div>

View File

@@ -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 ?? "<no-path>"}: ${error.message}`,
`REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,

View File

@@ -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 (
<div>
{apiKey ? (
<div className="flex gap-2">
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
<Input
value={`${apiKey.prefix}${apiKey.key}`}
readOnly
type="password"
/>
</div>
<div>
<Button variant="danger" onClick={handleRevokeAPIKey}>
Revoke
</Button>
</div>
</div>
) : (
<Button onClick={handleCreateAPIKey}>Create new key</Button>
)}
</div>
);
};
export default CreateAPIKeyForm;

View File

@@ -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() {
/>
</div>
{process.env.NEXT_PUBLIC_KAN_ENV !== "cloud" && (
{process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Billing
@@ -109,6 +113,19 @@ export default function SettingsPage() {
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
API keys
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
View and manage your API keys.
</p>
<CreateAPIKeyForm
apiKey={data?.apiKey}
refetchUser={refetchUser}
/>
</div>
<div className="border-t border-light-300 dark:border-dark-300">
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete workspace

View File

@@ -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({

View File

@@ -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 });
};

View File

@@ -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()],
});

View File

@@ -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),
});
};

View File

@@ -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],
}),
}));

View File

@@ -8,3 +8,5 @@ export * from "./labels";
export * from "./lists";
export * from "./users";
export * from "./workspaces";
export { apiKey as apikey } from "./auth";

View File

@@ -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(