mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
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:
@@ -84,7 +84,8 @@ class UserApiKeyController:
|
|||||||
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
|
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
|
||||||
)
|
)
|
||||||
result = await get_database().execute(query)
|
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()
|
user_api_keys_controller = UserApiKeyController()
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export default async function AppLayout({
|
|||||||
)}
|
)}
|
||||||
{featureEnabled("requireLogin") ? (
|
{featureEnabled("requireLogin") ? (
|
||||||
<>
|
<>
|
||||||
|
·
|
||||||
|
<Link
|
||||||
|
href="/settings/api-keys"
|
||||||
|
as={NextLink}
|
||||||
|
className="font-light px-2"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
·
|
·
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
</>
|
</>
|
||||||
|
|||||||
341
www/app/(app)/settings/api-keys/page.tsx
Normal file
341
www/app/(app)/settings/api-keys/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user