shin / frontend /src /components /PoolEditor.tsx
shinmentakezo07
feat: admin dedicated pages, pool key management, keypool quotas (#1350)
9b71fd0
Raw
History Blame Contribute Delete
5.41 kB
import { useState } from "react"
import { fetchPoolKeys } from "@/lib/api"
import { cn } from "@/lib/utils"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
const POOL_TOKEN_RE = /^__fcc_key_(\d+)__$/
const MASKED_SECRET = "********"
interface PoolItem {
token: string | null
raw?: string
}
interface PoolEditorProps {
value: string
locked: boolean
keyCount: number
fieldKey: string
disabled?: boolean
onChange: (value: string) => void
onMessage: (text: string, kind?: string) => void
}
function mask(raw: string): string {
return "●".repeat(Math.min(24, Math.max(12, raw.length)))
}
function tokenIndex(token: string): number {
const match = POOL_TOKEN_RE.exec(token)
return match ? Number(match[1]) : -1
}
export function PoolEditor({
value,
locked,
keyCount,
fieldKey,
disabled = false,
onChange,
onMessage,
}: PoolEditorProps) {
const [items, setItems] = useState<PoolItem[]>(() =>
(value || "").split(",").filter(Boolean).map((part) => ({ token: part })),
)
const [newKey, setNewKey] = useState("")
const [rawKeys, setRawKeys] = useState<string[] | null>(null)
const [revealIndex, setRevealIndex] = useState<number | null>(null)
const sync = (next: PoolItem[]) => {
setItems(next)
onChange(next.map((item) => item.token || item.raw || "").join(","))
}
const addKey = () => {
const raw = newKey.trim()
if (!raw) return
if (raw.includes(",")) {
onMessage("API keys cannot contain commas.", "error")
return
}
if (POOL_TOKEN_RE.test(raw)) {
onMessage("This key format is reserved; enter a real API key.", "error")
return
}
sync([...items, { token: null, raw }])
setNewKey("")
}
const reveal = async (index: number) => {
if (rawKeys === null) {
try {
const result = await fetchPoolKeys(fieldKey)
setRawKeys(result.keys)
} catch (error) {
onMessage(error instanceof Error ? error.message : String(error), "error")
return
}
}
setRevealIndex((current) => (current === index ? null : index))
}
const displayedValue = (item: PoolItem, index: number): string => {
if (revealIndex !== index) return item.token ? MASKED_SECRET : mask(item.raw ?? "")
if (item.token) {
const rawIndex = tokenIndex(item.token)
return rawKeys && rawIndex >= 0 && rawIndex < rawKeys.length
? rawKeys[rawIndex]
: MASKED_SECRET
}
return item.raw ?? ""
}
const editValue = (index: number, next: string) => {
sync(
items.map((item, itemIndex) => {
if (itemIndex !== index) return item
if (item.token) return { token: null, raw: next }
return { ...item, raw: next }
}),
)
}
const hint =
keyCount === 0
? "Add one or more API keys. Multiple keys rotate round-robin with failover."
: keyCount === 1
? "One key configured. Add extra keys for round-robin and failover."
: "Multiple keys are used in rotation. Add extra keys for round-robin and failover."
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
{items.map((item, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-md border bg-card px-2.5 py-1.5"
>
{revealIndex === index ? (
<Input
type="text"
autoComplete="off"
value={displayedValue(item, index)}
disabled={locked || disabled}
onChange={(event) => editValue(index, event.target.value)}
onBlur={() => setRevealIndex(null)}
className="h-8 flex-1 font-mono text-xs"
/>
) : (
<span className="min-w-0 flex-1 break-all font-mono text-xs text-muted-foreground">
{displayedValue(item, index)}
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
disabled={locked || disabled}
aria-label={revealIndex === index ? "Hide API key" : "Show API key"}
onClick={() => void reveal(index)}
>
{revealIndex === index ? "Hide" : "Show"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={locked || disabled}
aria-label="Remove API key"
onClick={() => sync(items.filter((_, i) => i !== index))}
>
Remove
</Button>
</div>
))}
</div>
<div className="flex gap-2">
<Input
type="password"
autoComplete="off"
placeholder="Add API key"
value={newKey}
disabled={locked || disabled}
onChange={(event) => setNewKey(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addKey()
}
}}
className={cn(locked && "opacity-50")}
/>
<Button type="button" variant="secondary" disabled={locked || disabled} onClick={addKey}>
Add
</Button>
</div>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
)
}