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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user