Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
69a8388
1
Parent(s): d712fd9
feat: editable proxy_api_key from Dashboard settings panel
Browse filesAdd GET/POST /admin/settings endpoints to read/write proxy_api_key
in config/default.yaml with hot-reload. Dashboard gets a collapsible
Settings panel with masked input, save, and clear key functionality.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- shared/hooks/use-settings.ts +57 -0
- shared/i18n/translations.ts +8 -0
- src/routes/web.ts +36 -1
- web/src/App.tsx +2 -0
- web/src/components/SettingsPanel.tsx +124 -0
shared/hooks/use-settings.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
|
| 3 |
+
export function useSettings() {
|
| 4 |
+
const [apiKey, setApiKey] = useState<string | null>(null);
|
| 5 |
+
const [loaded, setLoaded] = useState(false);
|
| 6 |
+
const [saving, setSaving] = useState(false);
|
| 7 |
+
const [error, setError] = useState<string | null>(null);
|
| 8 |
+
const [saved, setSaved] = useState(false);
|
| 9 |
+
|
| 10 |
+
const load = useCallback(async () => {
|
| 11 |
+
try {
|
| 12 |
+
const resp = await fetch("/admin/settings");
|
| 13 |
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
| 14 |
+
const data: { proxy_api_key: string | null } = await resp.json();
|
| 15 |
+
setApiKey(data.proxy_api_key);
|
| 16 |
+
setLoaded(true);
|
| 17 |
+
setError(null);
|
| 18 |
+
} catch (err) {
|
| 19 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 20 |
+
}
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
const save = useCallback(async (newKey: string | null) => {
|
| 24 |
+
setSaving(true);
|
| 25 |
+
setSaved(false);
|
| 26 |
+
setError(null);
|
| 27 |
+
try {
|
| 28 |
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
| 29 |
+
// Send current key for auth if one exists
|
| 30 |
+
if (apiKey) {
|
| 31 |
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
| 32 |
+
}
|
| 33 |
+
const resp = await fetch("/admin/settings", {
|
| 34 |
+
method: "POST",
|
| 35 |
+
headers,
|
| 36 |
+
body: JSON.stringify({ proxy_api_key: newKey }),
|
| 37 |
+
});
|
| 38 |
+
if (!resp.ok) {
|
| 39 |
+
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
|
| 40 |
+
throw new Error((data as { error?: string }).error ?? `HTTP ${resp.status}`);
|
| 41 |
+
}
|
| 42 |
+
const result: { proxy_api_key: string | null } = await resp.json();
|
| 43 |
+
setApiKey(result.proxy_api_key);
|
| 44 |
+
setSaved(true);
|
| 45 |
+
// Auto-clear saved indicator after 3s
|
| 46 |
+
setTimeout(() => setSaved(false), 3000);
|
| 47 |
+
} catch (err) {
|
| 48 |
+
setError(err instanceof Error ? err.message : String(err));
|
| 49 |
+
} finally {
|
| 50 |
+
setSaving(false);
|
| 51 |
+
}
|
| 52 |
+
}, [apiKey]);
|
| 53 |
+
|
| 54 |
+
useEffect(() => { load(); }, [load]);
|
| 55 |
+
|
| 56 |
+
return { apiKey, loaded, saving, saved, error, save, load };
|
| 57 |
+
}
|
shared/i18n/translations.ts
CHANGED
|
@@ -153,6 +153,10 @@ export const translations = {
|
|
| 153 |
updateRestarting: "Restarting server...",
|
| 154 |
restartFailed: "Server not responding. Please restart manually.",
|
| 155 |
close: "Close",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
},
|
| 157 |
zh: {
|
| 158 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -311,6 +315,10 @@ export const translations = {
|
|
| 311 |
updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
|
| 312 |
restartFailed: "\u670d\u52a1\u5668\u672a\u54cd\u5e94\uff0c\u8bf7\u624b\u52a8\u91cd\u542f\u3002",
|
| 313 |
close: "\u5173\u95ed",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
},
|
| 315 |
} as const;
|
| 316 |
|
|
|
|
| 153 |
updateRestarting: "Restarting server...",
|
| 154 |
restartFailed: "Server not responding. Please restart manually.",
|
| 155 |
close: "Close",
|
| 156 |
+
settings: "Settings",
|
| 157 |
+
apiKeyLabel: "API Key",
|
| 158 |
+
apiKeySaved: "Saved",
|
| 159 |
+
apiKeyClear: "Clear key (disable auth)",
|
| 160 |
},
|
| 161 |
zh: {
|
| 162 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 315 |
updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
|
| 316 |
restartFailed: "\u670d\u52a1\u5668\u672a\u54cd\u5e94\uff0c\u8bf7\u624b\u52a8\u91cd\u542f\u3002",
|
| 317 |
close: "\u5173\u95ed",
|
| 318 |
+
settings: "\u8bbe\u7f6e",
|
| 319 |
+
apiKeyLabel: "API \u5bc6\u94a5",
|
| 320 |
+
apiKeySaved: "\u5df2\u4fdd\u5b58",
|
| 321 |
+
apiKeyClear: "\u6e05\u9664\u5bc6\u94a5\uff08\u5173\u95ed\u9274\u6743\uff09",
|
| 322 |
},
|
| 323 |
} as const;
|
| 324 |
|
src/routes/web.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { serveStatic } from "@hono/node-server/serve-static";
|
|
| 3 |
import { readFileSync, existsSync } from "fs";
|
| 4 |
import { resolve } from "path";
|
| 5 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 6 |
-
import { getConfig, getFingerprint } from "../config.js";
|
| 7 |
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
|
| 8 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 9 |
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
|
|
|
|
| 10 |
|
| 11 |
export function createWebRoutes(accountPool: AccountPool): Hono {
|
| 12 |
const app = new Hono();
|
|
@@ -230,5 +231,39 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 230 |
return c.json(result);
|
| 231 |
});
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
return app;
|
| 234 |
}
|
|
|
|
| 3 |
import { readFileSync, existsSync } from "fs";
|
| 4 |
import { resolve } from "path";
|
| 5 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 6 |
+
import { getConfig, getFingerprint, reloadAllConfigs } from "../config.js";
|
| 7 |
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
|
| 8 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 9 |
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
|
| 10 |
+
import { mutateYaml } from "../utils/yaml-mutate.js";
|
| 11 |
|
| 12 |
export function createWebRoutes(accountPool: AccountPool): Hono {
|
| 13 |
const app = new Hono();
|
|
|
|
| 231 |
return c.json(result);
|
| 232 |
});
|
| 233 |
|
| 234 |
+
// --- Settings endpoints ---
|
| 235 |
+
|
| 236 |
+
app.get("/admin/settings", (c) => {
|
| 237 |
+
const config = getConfig();
|
| 238 |
+
return c.json({ proxy_api_key: config.server.proxy_api_key });
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
app.post("/admin/settings", async (c) => {
|
| 242 |
+
const config = getConfig();
|
| 243 |
+
const currentKey = config.server.proxy_api_key;
|
| 244 |
+
|
| 245 |
+
// Auth: if a key is currently set, require Bearer token matching it
|
| 246 |
+
if (currentKey) {
|
| 247 |
+
const authHeader = c.req.header("Authorization") ?? "";
|
| 248 |
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
| 249 |
+
if (token !== currentKey) {
|
| 250 |
+
c.status(401);
|
| 251 |
+
return c.json({ error: "Invalid current API key" });
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const body = await c.req.json() as { proxy_api_key?: string | null };
|
| 256 |
+
const newKey = body.proxy_api_key === undefined ? currentKey : (body.proxy_api_key || null);
|
| 257 |
+
|
| 258 |
+
const configPath = resolve(getConfigDir(), "default.yaml");
|
| 259 |
+
mutateYaml(configPath, (data) => {
|
| 260 |
+
const server = data.server as Record<string, unknown>;
|
| 261 |
+
server.proxy_api_key = newKey;
|
| 262 |
+
});
|
| 263 |
+
reloadAllConfigs();
|
| 264 |
+
|
| 265 |
+
return c.json({ success: true, proxy_api_key: newKey });
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
return app;
|
| 269 |
}
|
web/src/App.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { ProxyPool } from "./components/ProxyPool";
|
|
| 9 |
import { ApiConfig } from "./components/ApiConfig";
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
|
|
|
| 12 |
import { Footer } from "./components/Footer";
|
| 13 |
import { ProxySettings } from "./pages/ProxySettings";
|
| 14 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
|
@@ -142,6 +143,7 @@ function Dashboard() {
|
|
| 142 |
reasoningEffort={status.selectedEffort}
|
| 143 |
serviceTier={status.selectedSpeed}
|
| 144 |
/>
|
|
|
|
| 145 |
</div>
|
| 146 |
</main>
|
| 147 |
<Footer updateStatus={update.status} />
|
|
|
|
| 9 |
import { ApiConfig } from "./components/ApiConfig";
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
| 12 |
+
import { SettingsPanel } from "./components/SettingsPanel";
|
| 13 |
import { Footer } from "./components/Footer";
|
| 14 |
import { ProxySettings } from "./pages/ProxySettings";
|
| 15 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
|
|
|
| 143 |
reasoningEffort={status.selectedEffort}
|
| 144 |
serviceTier={status.selectedSpeed}
|
| 145 |
/>
|
| 146 |
+
<SettingsPanel />
|
| 147 |
</div>
|
| 148 |
</main>
|
| 149 |
<Footer updateStatus={update.status} />
|
web/src/components/SettingsPanel.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
import { useSettings } from "../../../shared/hooks/use-settings";
|
| 4 |
+
|
| 5 |
+
export function SettingsPanel() {
|
| 6 |
+
const t = useT();
|
| 7 |
+
const settings = useSettings();
|
| 8 |
+
const [draft, setDraft] = useState<string | null>(null);
|
| 9 |
+
const [revealed, setRevealed] = useState(false);
|
| 10 |
+
const [collapsed, setCollapsed] = useState(true);
|
| 11 |
+
|
| 12 |
+
// Sync draft when settings load
|
| 13 |
+
const displayValue = draft ?? settings.apiKey ?? "";
|
| 14 |
+
|
| 15 |
+
const handleSave = useCallback(async () => {
|
| 16 |
+
const newKey = (draft ?? settings.apiKey ?? "").trim() || null;
|
| 17 |
+
await settings.save(newKey);
|
| 18 |
+
setDraft(null);
|
| 19 |
+
}, [draft, settings]);
|
| 20 |
+
|
| 21 |
+
const handleClear = useCallback(async () => {
|
| 22 |
+
await settings.save(null);
|
| 23 |
+
setDraft(null);
|
| 24 |
+
setRevealed(false);
|
| 25 |
+
}, [settings]);
|
| 26 |
+
|
| 27 |
+
const isDirty = draft !== null && draft !== (settings.apiKey ?? "");
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl shadow-sm transition-colors">
|
| 31 |
+
{/* Header — clickable to toggle */}
|
| 32 |
+
<button
|
| 33 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 34 |
+
class="w-full flex items-center justify-between p-5 cursor-pointer select-none"
|
| 35 |
+
>
|
| 36 |
+
<div class="flex items-center gap-2">
|
| 37 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 38 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
|
| 39 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 40 |
+
</svg>
|
| 41 |
+
<h2 class="text-[0.95rem] font-bold">{t("settings")}</h2>
|
| 42 |
+
</div>
|
| 43 |
+
<svg class={`size-5 text-slate-400 dark:text-text-dim transition-transform ${collapsed ? "" : "rotate-180"}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 44 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 45 |
+
</svg>
|
| 46 |
+
</button>
|
| 47 |
+
|
| 48 |
+
{/* Collapsible content */}
|
| 49 |
+
{!collapsed && (
|
| 50 |
+
<div class="px-5 pb-5 border-t border-slate-100 dark:border-border-dark pt-4">
|
| 51 |
+
<div class="space-y-1.5">
|
| 52 |
+
<label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("apiKeyLabel")}</label>
|
| 53 |
+
<div class="flex items-center gap-2">
|
| 54 |
+
<div class="relative flex-1">
|
| 55 |
+
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-text-dim">
|
| 56 |
+
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 57 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
| 58 |
+
</svg>
|
| 59 |
+
</div>
|
| 60 |
+
<input
|
| 61 |
+
type={revealed ? "text" : "password"}
|
| 62 |
+
class="w-full pl-10 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-700 dark:text-text-main outline-none focus:ring-1 focus:ring-primary tracking-wider"
|
| 63 |
+
value={displayValue}
|
| 64 |
+
onInput={(e) => setDraft((e.target as HTMLInputElement).value)}
|
| 65 |
+
onFocus={() => setRevealed(true)}
|
| 66 |
+
placeholder={t("apiKeyLabel")}
|
| 67 |
+
/>
|
| 68 |
+
{/* Toggle visibility */}
|
| 69 |
+
<button
|
| 70 |
+
onClick={() => setRevealed(!revealed)}
|
| 71 |
+
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 dark:text-text-dim hover:text-slate-600 dark:hover:text-text-main"
|
| 72 |
+
title={revealed ? "Hide" : "Show"}
|
| 73 |
+
>
|
| 74 |
+
{revealed ? (
|
| 75 |
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 76 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
| 77 |
+
</svg>
|
| 78 |
+
) : (
|
| 79 |
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 80 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
| 81 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
| 82 |
+
</svg>
|
| 83 |
+
)}
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
{/* Save button */}
|
| 87 |
+
<button
|
| 88 |
+
onClick={handleSave}
|
| 89 |
+
disabled={settings.saving || !isDirty}
|
| 90 |
+
class={`px-4 py-2.5 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
|
| 91 |
+
isDirty && !settings.saving
|
| 92 |
+
? "bg-primary text-white hover:bg-primary/90 cursor-pointer"
|
| 93 |
+
: "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
|
| 94 |
+
}`}
|
| 95 |
+
>
|
| 96 |
+
{settings.saving ? "..." : t("submit")}
|
| 97 |
+
</button>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{/* Status messages */}
|
| 101 |
+
<div class="flex items-center gap-3 mt-2 min-h-[1.5rem]">
|
| 102 |
+
{settings.saved && (
|
| 103 |
+
<span class="text-xs font-medium text-green-600 dark:text-green-400">{t("apiKeySaved")}</span>
|
| 104 |
+
)}
|
| 105 |
+
{settings.error && (
|
| 106 |
+
<span class="text-xs font-medium text-red-500">{settings.error}</span>
|
| 107 |
+
)}
|
| 108 |
+
{/* Clear key button */}
|
| 109 |
+
{settings.apiKey && (
|
| 110 |
+
<button
|
| 111 |
+
onClick={handleClear}
|
| 112 |
+
disabled={settings.saving}
|
| 113 |
+
class="text-xs text-slate-400 dark:text-text-dim hover:text-red-500 dark:hover:text-red-400 transition-colors ml-auto"
|
| 114 |
+
>
|
| 115 |
+
{t("apiKeyClear")}
|
| 116 |
+
</button>
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
)}
|
| 122 |
+
</section>
|
| 123 |
+
);
|
| 124 |
+
}
|