icebear0828 Claude Opus 4.6 commited on
Commit
69a8388
·
1 Parent(s): d712fd9

feat: editable proxy_api_key from Dashboard settings panel

Browse files

Add 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 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
+ }