mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-17 19:24:19 +00:00
feat: Add API key authentication system for external service access (#1757)
* Initial plan * feat: Add API key authentication system backend Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat: Add API key management UI in frontend sidebar Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Correct import paths in API controller groups Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: Address code review feedback - add i18n and validation Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * refactor: Enable API key auth on existing endpoints instead of creating separate service API - Added USER_TOKEN_OR_API_KEY auth type that accepts both authentication methods - Removed separate /api/service/v1/models endpoints - Updated existing endpoints (models, bots, pipelines) to accept API keys - External services can now use API keys to access all existing LangBot APIs - Updated documentation to reflect unified API approach Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * docs: Add OpenAPI specification for API key authenticated endpoints Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * chore: rename openapi spec * perf: ui and i18n * fix: ui bug * chore: tidy docs * chore: fix linter errors --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ApiKeyManagementDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ApiKeyManagementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ApiKeyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyDescription, setNewKeyDescription] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
|
||||
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
|
||||
|
||||
// 清理 body 样式,防止对话框关闭后页面无法交互
|
||||
useEffect(() => {
|
||||
if (!deleteKeyId) {
|
||||
const cleanup = () => {
|
||||
document.body.style.removeProperty('pointer-events');
|
||||
};
|
||||
|
||||
cleanup();
|
||||
const timer = setTimeout(cleanup, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [deleteKeyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadApiKeys();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = (await backendClient.get('/api/v1/apikeys')) as {
|
||||
keys: ApiKey[];
|
||||
};
|
||||
setApiKeys(response.keys || []);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load API keys: ${error}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast.error(t('common.apiKeyNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await backendClient.post('/api/v1/apikeys', {
|
||||
name: newKeyName,
|
||||
description: newKeyDescription,
|
||||
})) as { key: ApiKey };
|
||||
|
||||
setCreatedKey(response.key);
|
||||
toast.success(t('common.apiKeyCreated'));
|
||||
setNewKeyName('');
|
||||
setNewKeyDescription('');
|
||||
setShowCreateDialog(false);
|
||||
loadApiKeys();
|
||||
} catch (error) {
|
||||
toast.error(`Failed to create API key: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async (keyId: number) => {
|
||||
try {
|
||||
await backendClient.delete(`/api/v1/apikeys/${keyId}`);
|
||||
toast.success(t('common.apiKeyDeleted'));
|
||||
loadApiKeys();
|
||||
setDeleteKeyId(null);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to delete API key: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
toast.success(t('common.apiKeyCopied'));
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
if (key.length <= 8) return key;
|
||||
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// 如果删除确认框是打开的,不允许关闭主对话框
|
||||
if (!newOpen && deleteKeyId) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.manageApiKeys')}</DialogTitle>
|
||||
<DialogDescription>{t('common.apiKeyHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('common.createApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('common.noApiKeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.name')}</TableHead>
|
||||
<TableHead>{t('common.apiKeyValue')}</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t('common.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{key.name}</div>
|
||||
{key.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{key.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(key.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyKey(key.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(key.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t('common.name')}</label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder={t('common.name')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.description')}
|
||||
</label>
|
||||
<Input
|
||||
value={newKeyDescription}
|
||||
onChange={(e) => setNewKeyDescription(e.target.value)}
|
||||
placeholder={t('common.description')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Show Created Key Dialog */}
|
||||
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('common.apiKeyCreatedMessage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.apiKeyValue')}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input value={createdKey?.key || ''} readOnly />
|
||||
<Button
|
||||
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedKey(null)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteKeyId}>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
className="z-[60]"
|
||||
onClick={() => setDeleteKeyId(null)}
|
||||
/>
|
||||
<AlertDialogPrimitive.Content
|
||||
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
|
||||
onEscapeKeyDown={() => setDeleteKeyId(null)}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('common.apiKeyDeleteConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
|
||||
import ApiKeyManagementDialog from '@/app/home/components/api-key-management-dialog/ApiKeyManagementDialog';
|
||||
|
||||
// TODO 侧边导航栏要加动画
|
||||
export default function HomeSidebar({
|
||||
@@ -45,6 +46,7 @@ export default function HomeSidebar({
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
|
||||
@@ -220,6 +222,23 @@ export default function HomeSidebar({
|
||||
name={t('common.helpDocs')}
|
||||
/>
|
||||
|
||||
<SidebarChild
|
||||
onClick={() => {
|
||||
setApiKeyDialogOpen(true);
|
||||
}}
|
||||
isSelected={false}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10.7577 11.8281L18.6066 3.97919L20.0208 5.3934L18.6066 6.80761L21.0815 9.28249L19.6673 10.6967L17.1924 8.22183L15.7782 9.63604L17.8995 11.7574L16.4853 13.1716L14.364 11.0503L12.1719 13.2423C13.4581 15.1837 13.246 17.8251 11.5355 19.5355C9.58291 21.4882 6.41709 21.4882 4.46447 19.5355C2.51184 17.5829 2.51184 14.4171 4.46447 12.4645C6.17493 10.754 8.81633 10.5419 10.7577 11.8281ZM10.1213 18.1213C11.2929 16.9497 11.2929 15.0503 10.1213 13.8787C8.94975 12.7071 7.05025 12.7071 5.87868 13.8787C4.70711 15.0503 4.70711 16.9497 5.87868 18.1213C7.05025 19.2929 8.94975 19.2929 10.1213 18.1213Z"></path>
|
||||
</svg>
|
||||
}
|
||||
name={t('common.apiKeys')}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -326,6 +345,10 @@ export default function HomeSidebar({
|
||||
open={passwordChangeOpen}
|
||||
onOpenChange={setPasswordChangeOpen}
|
||||
/>
|
||||
<ApiKeyManagementDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user