mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
* 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
342 lines
9.6 KiB
TypeScript
342 lines
9.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|