Files
goods/admin-frontend/src/components/ApiKeysPage.tsx
T
sulaimaannaasif6866 7434e5195e
CI / Go (api) (pull_request) Successful in 15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
feat(api): tiered cumulative quota + self-service registration
Anonymous callers get a free cumulative quota (1000 calls per IP); once
exhausted they get 403 quota_exhausted and must register. Public users can
self-register (email+password) to obtain a higher-quota API key, view usage,
and regenerate the key. Quota counters live in Redis; the public API stays
read-only except for the registration writes.

- migration 0011: app_user table + api_key.quota_total + 'registered' tier
- ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters
- middleware: enforce cumulative quota + X-Quota-* headers
- store: RegisterUser/Authenticate/RegenerateKey (bcrypt)
- handlers: POST /api/v1/register, /account, /account/regenerate
- admin: quota_total column + registered tier
- public: 'API 密钥' account page + API docs quota section

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 07:26:41 +00:00

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { api, ApiError } from "../api";
import type { ApiKey } from "../types";
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
const TIERS = [
{ key: "free", label: "免费 (free)", rate: 120, quota: 1000 },
{ key: "registered", label: "注册用户 (registered)", rate: 300, quota: 100000 },
{ key: "partner", label: "合作方 (partner)", rate: 600, quota: 0 },
{ key: "internal", label: "内部 (internal)", rate: 6000, quota: 0 },
];
function tierLabel(tier: string): string {
return TIERS.find((t) => t.key === tier)?.label ?? tier;
}
export default function ApiKeysPage() {
const [rows, setRows] = useState<ApiKey[]>([]);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
async function load() {
setError("");
try {
const res = await api.listApiKeys();
setRows(res.items);
} catch (e) {
setError(e instanceof ApiError ? e.message : "加载失败");
}
}
useEffect(() => {
load();
}, []);
async function revoke(id: string, name: string) {
if (!confirm(`确认吊销密钥「${name}」?使用该密钥的请求将立即被拒绝。`)) return;
try {
await api.revokeApiKey(id);
await load();
} catch (e) {
setError(e instanceof ApiError ? e.message : "操作失败");
}
}
return (
<div className="mx-auto max-w-4xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<KeyRound className="h-5 w-5 text-emerald-600" /> API
</h2>
<p className="text-sm text-gray-500 mt-1">
API
</p>
</div>
<button
onClick={() => setCreating(true)}
className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 flex items-center gap-1.5"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && (
<div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-4 py-2">{error}</div>
)}
{newKey && (
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="text-sm font-medium text-amber-800 mb-1">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white border rounded px-3 py-2 text-sm break-all">
{newKey}
</code>
<button
onClick={() => navigator.clipboard?.writeText(newKey)}
className="px-3 py-2 rounded border text-sm text-gray-600 hover:bg-gray-50 flex items-center gap-1"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={() => setNewKey(null)}
className="px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100"
>
</button>
</div>
</div>
)}
{creating && (
<CreateKeyForm
onClose={() => setCreating(false)}
onCreated={(plaintext) => {
setCreating(false);
setNewKey(plaintext);
load();
}}
/>
)}
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium">/</th>
<th className="px-4 py-2 font-medium">(/)</th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
) : (
rows.map((k) => (
<tr key={k.id} className={k.revoked_at ? "opacity-50" : ""}>
<td className="px-4 py-2 text-gray-800">
{k.name}
{k.owner_email && (
<span className="block text-xs text-gray-400">{k.owner_email}</span>
)}
</td>
<td className="px-4 py-2 text-gray-500">
<code>{k.key_prefix}</code>
</td>
<td className="px-4 py-2 text-gray-600">{tierLabel(k.tier)}</td>
<td className="px-4 py-2 text-gray-600">{k.rate_limit_per_min}</td>
<td className="px-4 py-2 text-gray-600">
{k.usage.today} / {k.usage.total}
</td>
<td className="px-4 py-2 text-gray-600">
{k.quota_total > 0 ? (
<span className={k.usage.total >= k.quota_total ? "text-red-600" : ""}>
{k.usage.total.toLocaleString()} / {k.quota_total.toLocaleString()}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2">
{k.revoked_at ? (
<span className="text-xs rounded px-2 py-0.5 bg-red-50 text-red-700">
</span>
) : (
<span className="text-xs rounded px-2 py-0.5 bg-emerald-50 text-emerald-700">
</span>
)}
</td>
<td className="px-4 py-2 text-right">
{!k.revoked_at && (
<button
onClick={() => revoke(k.id, k.name)}
className="text-gray-400 hover:text-red-600"
title="吊销"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
function CreateKeyForm({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (plaintext: string) => void;
}) {
const [name, setName] = useState("");
const [ownerEmail, setOwnerEmail] = useState("");
const [tier, setTier] = useState("free");
const [rate, setRate] = useState(120);
const [quota, setQuota] = useState(1000);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function pickTier(t: string) {
setTier(t);
const def = TIERS.find((x) => x.key === t);
if (def) {
setRate(def.rate);
setQuota(def.quota);
}
}
async function submit() {
if (!name.trim()) {
setError("名称不能为空");
return;
}
setBusy(true);
setError("");
try {
const res = await api.createApiKey({
name: name.trim(),
owner_email: ownerEmail.trim() || undefined,
tier,
rate_limit_per_min: rate,
quota_total: quota,
});
onCreated(res.key);
} catch (e) {
setError(e instanceof ApiError ? e.message : "创建失败");
} finally {
setBusy(false);
}
}
return (
<div className="mb-4 bg-white border rounded-lg p-5">
<h3 className="font-medium text-gray-700 mb-3"></h3>
{error && <div className="mb-3 bg-red-50 text-red-700 text-sm rounded px-3 py-2">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-xs text-gray-500"> *</span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:我的 App / 合作方 X"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<input
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={ownerEmail}
onChange={(e) => setOwnerEmail(e.target.value)}
placeholder="owner@example.com"
/>
</label>
<label className="block">
<span className="text-xs text-gray-500"></span>
<select
className="w-full border rounded-md px-3 py-2 text-sm mt-1 bg-white"
value={tier}
onChange={(e) => pickTier(e.target.value)}
>
{TIERS.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-gray-500">/</span>
<input
type="number"
min={1}
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={rate}
onChange={(e) => setRate(Math.max(1, parseInt(e.target.value || "1", 10)))}
/>
</label>
<label className="block">
<span className="text-xs text-gray-500">0=</span>
<input
type="number"
min={0}
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
value={quota}
onChange={(e) => setQuota(Math.max(0, parseInt(e.target.value || "0", 10)))}
/>
</label>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700 disabled:opacity-60"
>
</button>
<button onClick={onClose} className="px-4 py-2 rounded border text-sm text-gray-600">
</button>
</div>
</div>
);
}