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