300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||
|
|
import type { ApiKeyItem } from "@/lib/api/aiAssistant";
|
||
|
|
import {
|
||
|
|
Key,
|
||
|
|
Plus,
|
||
|
|
Copy,
|
||
|
|
Trash2,
|
||
|
|
Loader2,
|
||
|
|
Check,
|
||
|
|
Eye,
|
||
|
|
EyeOff,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
DialogTrigger,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from "@/components/ui/table";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
|
||
|
|
export default function AiAssistantApiKeysPage() {
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
|
|
const [newKeyDialogOpen, setNewKeyDialogOpen] = useState(false);
|
||
|
|
const [newKeyName, setNewKeyName] = useState("");
|
||
|
|
const [newKey, setNewKey] = useState("");
|
||
|
|
const [creating, setCreating] = useState(false);
|
||
|
|
const [showKey, setShowKey] = useState(false);
|
||
|
|
const [copied, setCopied] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadApiKeys();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadApiKeys = async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await aiAssistantApi.get("/api-keys");
|
||
|
|
setApiKeys(res.data?.data ?? []);
|
||
|
|
} catch {
|
||
|
|
toast.error("API 키 목록을 불러오는데 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const createApiKey = async () => {
|
||
|
|
if (!newKeyName.trim()) {
|
||
|
|
toast.error("키 이름을 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setCreating(true);
|
||
|
|
try {
|
||
|
|
const res = await aiAssistantApi.post("/api-keys", { name: newKeyName });
|
||
|
|
setNewKey((res.data?.data as { key?: string })?.key ?? "");
|
||
|
|
setCreateDialogOpen(false);
|
||
|
|
setNewKeyDialogOpen(true);
|
||
|
|
setNewKeyName("");
|
||
|
|
loadApiKeys();
|
||
|
|
toast.success("API 키가 생성되었습니다.");
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const msg =
|
||
|
|
err && typeof err === "object" && "response" in err
|
||
|
|
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data
|
||
|
|
?.error?.message
|
||
|
|
: null;
|
||
|
|
toast.error(msg ?? "API 키 생성에 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setCreating(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const revokeApiKey = async (id: number) => {
|
||
|
|
if (!confirm("이 API 키를 폐기하시겠습니까?")) return;
|
||
|
|
try {
|
||
|
|
await aiAssistantApi.delete(`/api-keys/${id}`);
|
||
|
|
loadApiKeys();
|
||
|
|
toast.success("API 키가 폐기되었습니다.");
|
||
|
|
} catch {
|
||
|
|
toast.error("API 키 폐기에 실패했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const copyToClipboard = async (text: string) => {
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(text);
|
||
|
|
setCopied(true);
|
||
|
|
toast.success("클립보드에 복사되었습니다.");
|
||
|
|
setTimeout(() => setCopied(false), 2000);
|
||
|
|
} catch {
|
||
|
|
toast.error("복사에 실패했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const baseUrl =
|
||
|
|
typeof window !== "undefined"
|
||
|
|
? process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || "http://localhost:3100/api/v1"
|
||
|
|
: "";
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-64 items-center justify-center">
|
||
|
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold tracking-tight">API 키 관리</h1>
|
||
|
|
<p className="text-muted-foreground mt-1">
|
||
|
|
외부 시스템에서 AI Assistant API를 사용하기 위한 키를 관리합니다.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
새 API 키
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>새 API 키 생성</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
새로운 API 키를 생성합니다. 키는 한 번만 표시되므로 안전하게 보관하세요.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4 py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="keyName">키 이름</Label>
|
||
|
|
<Input
|
||
|
|
id="keyName"
|
||
|
|
placeholder="예: Production Server"
|
||
|
|
value={newKeyName}
|
||
|
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={createApiKey} disabled={creating}>
|
||
|
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
|
|
생성
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog open={newKeyDialogOpen} onOpenChange={setNewKeyDialogOpen}>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>API 키가 생성되었습니다</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
이 키는 다시 표시되지 않습니다. 안전한 곳에 복사하여 보관하세요.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4 py-4">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
type={showKey ? "text" : "password"}
|
||
|
|
value={newKey}
|
||
|
|
readOnly
|
||
|
|
className="font-mono"
|
||
|
|
/>
|
||
|
|
<Button variant="outline" size="icon" onClick={() => setShowKey(!showKey)}>
|
||
|
|
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" size="icon" onClick={() => copyToClipboard(newKey)}>
|
||
|
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button onClick={() => setNewKeyDialogOpen(false)}>확인</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>API 키 목록</CardTitle>
|
||
|
|
<CardDescription>발급된 모든 API 키를 확인하고 관리합니다.</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{apiKeys.length === 0 ? (
|
||
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
|
|
<Key className="text-muted-foreground mb-4 h-12 w-12" />
|
||
|
|
<h3 className="text-lg font-medium">API 키가 없습니다</h3>
|
||
|
|
<p className="text-muted-foreground mt-1 text-sm">새 API 키를 생성하여 시작하세요.</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>이름</TableHead>
|
||
|
|
<TableHead>키</TableHead>
|
||
|
|
<TableHead>상태</TableHead>
|
||
|
|
<TableHead>사용량</TableHead>
|
||
|
|
<TableHead>마지막 사용</TableHead>
|
||
|
|
<TableHead>생성일</TableHead>
|
||
|
|
<TableHead className="text-right">작업</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{apiKeys.map((key) => (
|
||
|
|
<TableRow key={key.id}>
|
||
|
|
<TableCell className="font-medium">{key.name}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<code className="bg-muted rounded px-2 py-1 text-sm">
|
||
|
|
{key.keyPrefix}...
|
||
|
|
</code>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => copyToClipboard(key.keyPrefix + "...")}
|
||
|
|
>
|
||
|
|
<Copy className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant={key.status === "active" ? "success" : "secondary"}>
|
||
|
|
{key.status === "active" ? "활성" : "폐기됨"}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{(key.usageCount ?? 0).toLocaleString()} 토큰</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{key.lastUsedAt
|
||
|
|
? new Date(key.lastUsedAt).toLocaleDateString("ko-KR")
|
||
|
|
: "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{new Date(key.createdAt).toLocaleDateString("ko-KR")}</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{key.status === "active" && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||
|
|
onClick={() => revokeApiKey(key.id)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>API 사용 방법</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
발급받은 API 키를 Authorization 헤더에 포함하여 요청하세요.
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<pre className="bg-muted overflow-x-auto rounded-lg p-4 text-sm">
|
||
|
|
{`curl -X POST ${baseUrl}/chat/completions \\
|
||
|
|
-H "Content-Type: application/json" \\
|
||
|
|
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||
|
|
-d '{"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hello!"}]}'`}
|
||
|
|
</pre>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|