From 372202b0e1a86823900b0aa77be1bfbc2893d8a1 Mon Sep 17 00:00:00 2001 From: Jose Date: Mon, 10 Nov 2025 18:25:08 -0500 Subject: [PATCH] feat: add API key management UI (#716) * feat: add API key management UI - Created settings page for users to create, view, and delete API keys - Added Settings link to app navigation header - Fixed delete operation return value handling in backend to properly handle asyncpg's None response * feat: replace browser confirm with dialog for API key deletion - Added Chakra UI Dialog component for better UX when confirming API key deletion - Implemented proper focus management with cancelRef for accessibility - Replaced native browser confirm() with controlled dialog state * style: format API keys page with consistent line breaks * feat: auto-select API key text for easier copying - Added automatic text selection after API key creation to streamline the copy workflow - Applied className to Code component for DOM targeting * feat: improve API keys page layout and responsiveness - Reduced max width from 1200px to 800px for better readability - Added explicit width constraint to ensure consistent sizing across viewports * refactor: remove redundant comments from API keys page --- server/reflector/db/user_api_keys.py | 3 +- www/app/(app)/layout.tsx | 8 + www/app/(app)/settings/api-keys/page.tsx | 341 +++++++++++++++++++++++ 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 www/app/(app)/settings/api-keys/page.tsx diff --git a/server/reflector/db/user_api_keys.py b/server/reflector/db/user_api_keys.py index b4fe7538..8e0ab928 100644 --- a/server/reflector/db/user_api_keys.py +++ b/server/reflector/db/user_api_keys.py @@ -84,7 +84,8 @@ class UserApiKeyController: (user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id) ) result = await get_database().execute(query) - return result > 0 + # asyncpg returns None for DELETE, consider it success if no exception + return result is None or result > 0 user_api_keys_controller = UserApiKeyController() diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 8bca1df6..7d9f1c84 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -78,6 +78,14 @@ export default async function AppLayout({ )} {featureEnabled("requireLogin") ? ( <> +  ·  + + Settings +  ·  diff --git a/www/app/(app)/settings/api-keys/page.tsx b/www/app/(app)/settings/api-keys/page.tsx new file mode 100644 index 00000000..e63ed07a --- /dev/null +++ b/www/app/(app)/settings/api-keys/page.tsx @@ -0,0 +1,341 @@ +"use client"; +import React, { useState, useRef } from "react"; +import { + Box, + Button, + Heading, + Stack, + Text, + Input, + Table, + Flex, + IconButton, + Code, + Dialog, +} from "@chakra-ui/react"; +import { LuTrash2, LuCopy, LuPlus } from "react-icons/lu"; +import { useQueryClient } from "@tanstack/react-query"; +import { $api } from "../../../lib/apiClient"; +import { toaster } from "../../../components/ui/toaster"; + +interface CreateApiKeyResponse { + id: string; + user_id: string; + name: string | null; + created_at: string; + key: string; +} + +export default function ApiKeysPage() { + const [newKeyName, setNewKeyName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [createdKey, setCreatedKey] = useState( + null, + ); + const [keyToDelete, setKeyToDelete] = useState(null); + const queryClient = useQueryClient(); + const cancelRef = useRef(null); + + const { data: apiKeys, isLoading } = $api.useQuery( + "get", + "/v1/user/api-keys", + ); + + const createKeyMutation = $api.useMutation("post", "/v1/user/api-keys", { + onSuccess: (data) => { + setCreatedKey(data); + setNewKeyName(""); + setIsCreating(false); + queryClient.invalidateQueries({ queryKey: ["get", "/v1/user/api-keys"] }); + toaster.create({ + duration: 5000, + render: () => ( + + API key created + + Make sure to copy it now - you won't see it again! + + + ), + }); + + setTimeout(() => { + const keyElement = document.querySelector(".api-key-code"); + if (keyElement) { + const range = document.createRange(); + range.selectNodeContents(keyElement); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }, 100); + }, + onError: () => { + toaster.create({ + duration: 3000, + render: () => ( + + Error + Failed to create API key + + ), + }); + }, + }); + + const deleteKeyMutation = $api.useMutation( + "delete", + "/v1/user/api-keys/{key_id}", + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/v1/user/api-keys"], + }); + toaster.create({ + duration: 3000, + render: () => ( + + API key deleted + + ), + }); + }, + onError: () => { + toaster.create({ + duration: 3000, + render: () => ( + + Error + Failed to delete API key + + ), + }); + }, + }, + ); + + const handleCreateKey = () => { + createKeyMutation.mutate({ + body: { name: newKeyName || null }, + }); + }; + + const handleCopyKey = (key: string) => { + navigator.clipboard.writeText(key); + toaster.create({ + duration: 2000, + render: () => ( + + Copied to clipboard + + ), + }); + }; + + const handleDeleteRequest = (keyId: string) => { + setKeyToDelete(keyId); + }; + + const confirmDelete = () => { + if (keyToDelete) { + deleteKeyMutation.mutate({ + params: { path: { key_id: keyToDelete } }, + }); + setKeyToDelete(null); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( + + API Keys + + Manage your API keys for programmatic access to Reflector + + + {/* Show newly created key */} + {createdKey && ( + + + + API Key Created + + + + + Make sure to copy your API key now. You won't be able to see it + again! + + + + {createdKey.key} + + handleCopyKey(createdKey.key)} + > + + + + + )} + + {/* Create new key */} + + + Create New API Key + + {!isCreating ? ( + + ) : ( + + + Name (optional) + setNewKeyName(e.target.value)} + /> + + + + + + + )} + + + {/* List of API keys */} + + + Your API Keys + + {isLoading ? ( + Loading... + ) : !apiKeys || apiKeys.length === 0 ? ( + + No API keys yet. Create one to get started. + + ) : ( + + + + Name + Created + Actions + + + + {apiKeys.map((key) => ( + + + {key.name || Unnamed} + + {formatDate(key.created_at)} + + handleDeleteRequest(key.id)} + loading={ + deleteKeyMutation.isPending && + deleteKeyMutation.variables?.params?.path?.key_id === + key.id + } + > + + + + + ))} + + + )} + + + {/* Delete confirmation dialog */} + { + if (!e.open) setKeyToDelete(null); + }} + initialFocusEl={() => cancelRef.current} + > + + + + + Delete API Key + + + + Are you sure you want to delete this API key? This action cannot + be undone. + + + + + + + + + + + ); +}