feat: create and delete api keys
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef, useState } from "react";
|
||||||
import ContentEditable from "react-contenteditable";
|
import ContentEditable from "react-contenteditable";
|
||||||
|
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
@@ -14,6 +15,7 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => void;
|
) => void;
|
||||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
@@ -27,10 +29,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
onKeyDown,
|
onKeyDown,
|
||||||
iconRight,
|
iconRight,
|
||||||
className,
|
className,
|
||||||
|
type = "text",
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
if (contentEditable) {
|
if (contentEditable) {
|
||||||
return (
|
return (
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
@@ -56,7 +61,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
type={type === "password" && showPassword ? "text" : type}
|
||||||
className={twMerge(
|
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",
|
"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",
|
prefix && "rounded-l-none",
|
||||||
@@ -64,7 +71,21 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...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">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
{iconRight}
|
{iconRight}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import cors from "nextjs-cors";
|
import cors from "nextjs-cors";
|
||||||
import { createOpenApiNextHandler } from "trpc-to-openapi";
|
import { createOpenApiNextHandler } from "trpc-to-openapi";
|
||||||
|
|
||||||
@@ -16,12 +16,11 @@ export default async function handler(
|
|||||||
const openApiHandler = createOpenApiNextHandler({
|
const openApiHandler = createOpenApiNextHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createRESTContext,
|
createContext: createRESTContext,
|
||||||
responseMeta: () => ({ headers: {} }),
|
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
60
apps/web/src/views/settings/components/CreateAPIKeyForm.tsx
Normal file
60
apps/web/src/views/settings/components/CreateAPIKeyForm.tsx
Normal 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;
|
||||||
@@ -8,6 +8,7 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import Avatar from "./components/Avatar";
|
import Avatar from "./components/Avatar";
|
||||||
|
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||||
@@ -18,9 +19,12 @@ import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { modalContentType, openModal } = useModal();
|
const { modalContentType, openModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { data } = api.user.getUser.useQuery();
|
const { data } = api.user.getUser.useQuery();
|
||||||
|
|
||||||
|
const refetchUser = () => utils.user.getUser.refetch();
|
||||||
|
|
||||||
const handleOpenBillingPortal = async () => {
|
const handleOpenBillingPortal = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/stripe/create_billing_session", {
|
const response = await fetch("/api/stripe/create_billing_session", {
|
||||||
@@ -91,7 +95,7 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
Billing
|
Billing
|
||||||
@@ -109,6 +113,19 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</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">
|
<div className="border-t border-light-300 dark:border-dark-300">
|
||||||
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
<h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||||
Delete workspace
|
Delete workspace
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
image: z.string().nullable(),
|
image: z.string().nullable(),
|
||||||
stripeCustomerId: z.string().nullable(),
|
stripeCustomerId: z.string().nullable(),
|
||||||
|
apiKey: z
|
||||||
|
.object({
|
||||||
|
id: z.number(),
|
||||||
|
prefix: z.string().nullable(),
|
||||||
|
key: z.string(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx }) => {
|
.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
|
update: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -57,23 +57,20 @@ export const createNextApiContext = async (req: NextApiRequest) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
const accessToken = authHeader?.startsWith("Bearer ")
|
|
||||||
? authHeader.substring(7)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const db = createDrizzleClient();
|
const db = createDrizzleClient();
|
||||||
const auth = initAuth(db);
|
const auth = initAuth(db);
|
||||||
|
|
||||||
if (!accessToken) {
|
let session;
|
||||||
return createInnerTRPCContext({ db, user: null });
|
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 });
|
return createInnerTRPCContext({ db, user: session?.user });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
plugins: [magicLinkClient()],
|
plugins: [magicLinkClient(), apiKeyClient()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
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) => {
|
export const getById = async (db: dbClient, userId: string) => {
|
||||||
return await db.query.users.findFirst({
|
return await db.query.users.findFirst({
|
||||||
@@ -13,6 +13,17 @@ export const getById = async (db: dbClient, userId: string) => {
|
|||||||
image: true,
|
image: true,
|
||||||
stripeCustomerId: true,
|
stripeCustomerId: true,
|
||||||
},
|
},
|
||||||
|
with: {
|
||||||
|
apiKeys: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
prefix: true,
|
||||||
|
key: true,
|
||||||
|
},
|
||||||
|
orderBy: desc(apiKey.createdAt),
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
where: eq(users.id, userId),
|
where: eq(users.id, userId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
bigserial,
|
bigserial,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -50,7 +51,7 @@ export const verification = pgTable("verification", {
|
|||||||
updatedAt: timestamp("updatedAt"),
|
updatedAt: timestamp("updatedAt"),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const apiKey = pgTable("apiKey", {
|
export const apiKey = pgTable("apikey", {
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
start: text("start"),
|
start: text("start"),
|
||||||
@@ -75,3 +76,10 @@ export const apiKey = pgTable("apiKey", {
|
|||||||
permissions: text("permissions"),
|
permissions: text("permissions"),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
|
export const apiKeyRelations = relations(apiKey, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [apiKey.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ export * from "./labels";
|
|||||||
export * from "./lists";
|
export * from "./lists";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
|
|
||||||
|
export { apiKey as apikey } from "./auth";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { apiKey } from "./auth";
|
||||||
import { boards } from "./boards";
|
import { boards } from "./boards";
|
||||||
import { cards } from "./cards";
|
import { cards } from "./cards";
|
||||||
import { imports } from "./imports";
|
import { imports } from "./imports";
|
||||||
@@ -33,6 +34,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
imports: many(imports),
|
imports: many(imports),
|
||||||
lists: many(lists),
|
lists: many(lists),
|
||||||
workspaces: many(workspaces),
|
workspaces: many(workspaces),
|
||||||
|
apiKeys: many(apiKey),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersToWorkspacesRelations = relations(
|
export const usersToWorkspacesRelations = relations(
|
||||||
|
|||||||
Reference in New Issue
Block a user