Add TOTP-gated provider API key reveal

Allow super administrators to inspect effective provider credentials only after audited, rate-limited step-up verification.
This commit is contained in:
Rocky
2026-08-22 17:56:35 +08:00
parent 636a8541bc
commit 544e0d7356
14 changed files with 524 additions and 6 deletions
@@ -1,4 +1,4 @@
import { KeyRound, RefreshCw, ServerCog } from "lucide-react";
import { Eye, EyeOff, KeyRound, RefreshCw, ServerCog } from "lucide-react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
@@ -11,6 +11,7 @@ import {
Badge,
Button,
Card,
Dialog,
ErrorState,
Input,
LoadingState,
@@ -74,7 +75,7 @@ export function ProvidersPage() {
<PageHeader
eyebrow="Managed Providers"
title="Provider 配置"
description="替换托管网关使用的上游 API Key。新配置只影响之后开始的请求,现有密钥不会返回浏览器。"
description="查看或替换托管网关使用的上游 API Key。新配置只影响之后开始的请求。"
actions={
<Button variant="secondary" onClick={() => void load()}>
<RefreshCw className="size-4" aria-hidden />
@@ -89,7 +90,7 @@ export function ProvidersPage() {
<KeyRound className="size-4" aria-hidden />
</span>
<p>
Key Key
Key
</p>
</div>
</Card>
@@ -124,8 +125,51 @@ function ProviderCard({
onUpdated: (status: ManagedProviderStatus) => void;
}) {
const [apiKey, setApiKey] = useState("");
const [currentApiKey, setCurrentApiKey] = useState<string>();
const [revealOpen, setRevealOpen] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [revealError, setRevealError] = useState("");
const [revealing, setRevealing] = useState(false);
const [saving, setSaving] = useState(false);
function toggleCurrentApiKey() {
if (currentApiKey) {
setCurrentApiKey(undefined);
return;
}
setTotpCode("");
setRevealError("");
setRevealOpen(true);
}
function handleRevealOpenChange(open: boolean) {
setRevealOpen(open);
if (!open) {
setTotpCode("");
setRevealError("");
}
}
async function reveal(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!/^\d{6}$/.test(totpCode)) {
setRevealError("请输入 6 位动态验证码");
return;
}
setRevealing(true);
setRevealError("");
try {
const response = await adminApi.revealProviderApiKey(providerId, { totpCode });
setCurrentApiKey(response.apiKey);
handleRevealOpenChange(false);
} catch (error) {
setRevealError(error instanceof ApiError ? error.message : "当前 API Key 读取失败");
} finally {
setRevealing(false);
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalized = apiKey.trim();
@@ -143,6 +187,7 @@ function ProviderCard({
apiKey: normalized,
});
setApiKey("");
setCurrentApiKey((current) => (current ? normalized : undefined));
onUpdated(updated);
toast.success(`${name} API Key 已替换`);
} catch (error) {
@@ -179,6 +224,48 @@ function ProviderCard({
</dl>
</div>
<div className="border-b border-border p-5 sm:p-6">
<label
className="block text-sm font-semibold"
htmlFor={`${providerId}-current-api-key`}
>
<span className="mb-2 block">{name} API Key</span>
</label>
<div className="relative">
<Input
id={`${providerId}-current-api-key`}
className="pr-12 font-mono"
type="text"
autoComplete="off"
readOnly
spellCheck={false}
value={
status?.configured
? currentApiKey ?? "••••••••••••••••"
: "未配置"
}
/>
<Button
className="absolute right-0.5 top-0.5"
type="button"
variant="ghost"
size="icon"
disabled={!status?.configured || revealing}
aria-label={currentApiKey ? `隐藏 ${name} 当前 API Key` : `显示 ${name} 当前 API Key`}
onClick={toggleCurrentApiKey}
>
{currentApiKey ? (
<EyeOff className="size-4" aria-hidden />
) : (
<Eye className="size-4" aria-hidden />
)}
</Button>
</div>
<p className="mt-2 text-xs leading-5 text-muted">
</p>
</div>
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
<span className="mb-2 block">{name} API Key</span>
@@ -194,13 +281,54 @@ function ProviderCard({
/>
</label>
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
Key
Key Key
</p>
<Button type="submit" loading={saving} disabled={!apiKey.trim()}>
<KeyRound className="size-4" aria-hidden />
{name} API Key
</Button>
</form>
<Dialog
open={revealOpen}
onOpenChange={handleRevealOpenChange}
title={`验证后显示 ${name} API Key`}
description="请输入当前管理员的 6 位动态验证码。本次查看会写入审计日志。"
>
<form className="space-y-5" onSubmit={(event) => void reveal(event)} noValidate>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-reveal-totp`}>
<span className="mb-2 block"></span>
<Input
id={`${providerId}-reveal-totp`}
className="text-center text-lg font-semibold tracking-[0.35em]"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, ""))}
autoFocus
/>
</label>
{revealError ? (
<p className="text-sm text-danger" role="alert">
{revealError}
</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => handleRevealOpenChange(false)}
>
</Button>
<Button type="submit" loading={revealing} disabled={totpCode.length !== 6}>
</Button>
</div>
</form>
</Dialog>
</Card>
);
}