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
This commit is contained in:
2025-11-10 18:25:08 -05:00
committed by GitHub
parent d20aac66c4
commit 372202b0e1
3 changed files with 351 additions and 1 deletions

View File

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

View File

@@ -78,6 +78,14 @@ export default async function AppLayout({
)}
{featureEnabled("requireLogin") ? (
<>
&nbsp;·&nbsp;
<Link
href="/settings/api-keys"
as={NextLink}
className="font-light px-2"
>
Settings
</Link>
&nbsp;·&nbsp;
<UserInfo />
</>

View File

@@ -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<CreateApiKeyResponse | null>(
null,
);
const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
const queryClient = useQueryClient();
const cancelRef = useRef<HTMLButtonElement>(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: () => (
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">API key created</Text>
<Text fontSize="sm">
Make sure to copy it now - you won't see it again!
</Text>
</Box>
),
});
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: () => (
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Error</Text>
<Text fontSize="sm">Failed to create API key</Text>
</Box>
),
});
},
});
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: () => (
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">API key deleted</Text>
</Box>
),
});
},
onError: () => {
toaster.create({
duration: 3000,
render: () => (
<Box bg="red.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Error</Text>
<Text fontSize="sm">Failed to delete API key</Text>
</Box>
),
});
},
},
);
const handleCreateKey = () => {
createKeyMutation.mutate({
body: { name: newKeyName || null },
});
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
toaster.create({
duration: 2000,
render: () => (
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Copied to clipboard</Text>
</Box>
),
});
};
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 (
<Box maxW="800px" w="100%" mx="auto" p={8}>
<Heading mb={2}>API Keys</Heading>
<Text color="gray.600" mb={6}>
Manage your API keys for programmatic access to Reflector
</Text>
{/* Show newly created key */}
{createdKey && (
<Box
mb={6}
p={4}
bg="green.50"
borderWidth={1}
borderColor="green.200"
borderRadius="md"
>
<Flex justify="space-between" align="start" mb={2}>
<Heading size="sm" color="green.800">
API Key Created
</Heading>
<Button
size="sm"
variant="ghost"
onClick={() => setCreatedKey(null)}
>
×
</Button>
</Flex>
<Text mb={2} fontSize="sm" color="green.700">
Make sure to copy your API key now. You won't be able to see it
again!
</Text>
<Flex gap={2} align="center">
<Code
p={2}
flex={1}
fontSize="sm"
bg="white"
className="api-key-code"
>
{createdKey.key}
</Code>
<IconButton
aria-label="Copy API key"
size="sm"
onClick={() => handleCopyKey(createdKey.key)}
>
<LuCopy />
</IconButton>
</Flex>
</Box>
)}
{/* Create new key */}
<Box mb={8} p={6} borderWidth={1} borderRadius="md">
<Heading size="md" mb={4}>
Create New API Key
</Heading>
{!isCreating ? (
<Button onClick={() => setIsCreating(true)} colorPalette="blue">
<LuPlus /> Create API Key
</Button>
) : (
<Stack gap={4}>
<Box>
<Text mb={2}>Name (optional)</Text>
<Input
placeholder="e.g., Production API Key"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</Box>
<Flex gap={2}>
<Button
onClick={handleCreateKey}
colorPalette="blue"
loading={createKeyMutation.isPending}
>
Create
</Button>
<Button
onClick={() => {
setIsCreating(false);
setNewKeyName("");
}}
variant="outline"
>
Cancel
</Button>
</Flex>
</Stack>
)}
</Box>
{/* List of API keys */}
<Box>
<Heading size="md" mb={4}>
Your API Keys
</Heading>
{isLoading ? (
<Text>Loading...</Text>
) : !apiKeys || apiKeys.length === 0 ? (
<Text color="gray.600">
No API keys yet. Create one to get started.
</Text>
) : (
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>Name</Table.ColumnHeader>
<Table.ColumnHeader>Created</Table.ColumnHeader>
<Table.ColumnHeader>Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{apiKeys.map((key) => (
<Table.Row key={key.id}>
<Table.Cell>
{key.name || <Text color="gray.500">Unnamed</Text>}
</Table.Cell>
<Table.Cell>{formatDate(key.created_at)}</Table.Cell>
<Table.Cell>
<IconButton
aria-label="Delete API key"
size="sm"
colorPalette="red"
variant="ghost"
onClick={() => handleDeleteRequest(key.id)}
loading={
deleteKeyMutation.isPending &&
deleteKeyMutation.variables?.params?.path?.key_id ===
key.id
}
>
<LuTrash2 />
</IconButton>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</Box>
{/* Delete confirmation dialog */}
<Dialog.Root
open={!!keyToDelete}
onOpenChange={(e) => {
if (!e.open) setKeyToDelete(null);
}}
initialFocusEl={() => cancelRef.current}
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Delete API Key
</Dialog.Header>
<Dialog.Body>
<Text>
Are you sure you want to delete this API key? This action cannot
be undone.
</Text>
</Dialog.Body>
<Dialog.Footer>
<Button
ref={cancelRef}
onClick={() => setKeyToDelete(null)}
variant="outline"
colorPalette="gray"
>
Cancel
</Button>
<Button colorPalette="red" onClick={confirmDelete} ml={3}>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</Box>
);
}