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