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:
Copilot
2025-11-07 14:08:11 +08:00
committed by GitHub
parent af82227dff
commit a076ce5756
18 changed files with 2899 additions and 13 deletions
@@ -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>
);
}