feat(cli-tools): support saving and managing custom API key presets

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
decolua
2026-09-03 09:06:08 +07:00
parent 1fc2a81d65
commit c24a854278
3 changed files with 140 additions and 75 deletions

View File

@@ -1,38 +1,76 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { readKeyPresets, upsertKeyPreset, deleteKeyPreset, subscribeKeyPresets } from "./cliEndpointPresets";
const CUSTOM_VALUE = "__custom__"; const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save_key__";
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) { export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
const isCustom = !apiKeys.some((k) => k.key === value) && value !== ""; const [savedKeys, setSavedKeys] = useState([]);
const [mode, setMode] = useState(() => { // Custom mode is sticky once the user types, so an emptied input doesn't jump back to a dropdown option
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE; const [customMode, setCustomMode] = useState(false);
if (apiKeys.some((k) => k.key === value)) return value; const [customInput, setCustomInput] = useState("");
return CUSTOM_VALUE;
}); useEffect(() => {
const [customInput, setCustomInput] = useState(isCustom ? value : ""); const sync = () => setSavedKeys(readKeyPresets());
sync();
return subscribeKeyPresets(sync);
}, []);
const options = useMemo(
() => [
...apiKeys.map((k) => ({ value: k.key, label: k.key })),
...savedKeys.map((p) => ({ value: `saved:${p.name}`, label: p.key, url: p.key, saved: true })),
{ value: CUSTOM_VALUE, label: "Custom...", url: "" },
],
[apiKeys, savedKeys]
);
// Derive the active option from value — no sync effects needed when the parent updates it
const matched = value ? options.find((o) => o.value === value || o.url === value) : null;
const mode = matched ? matched.value : (customMode || value ? CUSTOM_VALUE : (options[0]?.value ?? CUSTOM_VALUE));
const inputValue = customMode ? customInput : (value || "");
const isSaved = typeof mode === "string" && mode.startsWith("saved:");
const isCustom = mode === CUSTOM_VALUE;
const canSave = isCustom && (value || "").trim().length > 0 && !apiKeys.some((k) => k.key === value);
const noKeys = apiKeys.length === 0 && savedKeys.length === 0 && !customMode && !value;
const handleSelect = (e) => { const handleSelect = (e) => {
const next = e.target.value; const next = e.target.value;
setMode(next); if (next === SAVE_VALUE) {
upsertKeyPreset((value || "").trim());
return;
}
if (next === CUSTOM_VALUE) { if (next === CUSTOM_VALUE) {
setCustomMode(true);
setCustomInput(""); setCustomInput("");
onChange(""); onChange("");
} else { return;
onChange(next);
} }
setCustomMode(false);
setCustomInput("");
const opt = options.find((o) => o.value === next);
if (opt) onChange(opt.url ?? opt.value);
}; };
const handleCustomInput = (e) => { const handleCustomInput = (e) => {
const v = e.target.value; const v = e.target.value;
setCustomMode(true);
setCustomInput(v); setCustomInput(v);
onChange(v); onChange(v);
}; };
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE; const handleDeleteSaved = () => {
if (!isSaved) return;
deleteKeyPreset(mode.slice(6));
setCustomMode(false);
setCustomInput("");
const fallback = options.find((o) => o.value !== CUSTOM_VALUE && o.value !== mode);
onChange(fallback ? (fallback.url ?? fallback.value) : "");
};
if (noKeys && mode !== CUSTOM_VALUE) { if (noKeys) {
return ( return (
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}> <span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
@@ -42,20 +80,27 @@ export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabl
return ( return (
<div className={`flex flex-col gap-1.5 ${className}`}> <div className={`flex flex-col gap-1.5 ${className}`}>
<div className="flex items-center gap-2">
<select <select
value={mode} value={mode}
onChange={handleSelect} onChange={handleSelect}
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
> >
{apiKeys.map((k) => ( {options.map((o) => (
<option key={k.id} value={k.key}>{k.key}</option> <option key={o.value} value={o.value}>{o.label}</option>
))} ))}
<option value={CUSTOM_VALUE}>Custom...</option> {canSave && <option value={SAVE_VALUE}>+ Save current as...</option>}
</select> </select>
{mode === CUSTOM_VALUE && ( {isSaved && (
<button type="button" onClick={handleDeleteSaved} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors shrink-0" title="Delete saved key">
<span className="material-symbols-outlined text-[14px]">delete</span>
</button>
)}
</div>
{isCustom && (
<input <input
type="text" type="text"
value={customInput} value={inputValue}
onChange={handleCustomInput} onChange={handleCustomInput}
placeholder="sk-..." placeholder="sk-..."
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"

View File

@@ -131,9 +131,9 @@ export default function ClaudeToolCard({
} }
} }
}); });
// Only set selectedApiKey if it exists in apiKeys list // Restore key from settings.json; ApiKeySelect matches it against saved presets
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN; const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) { if (tokenFromFile) {
setSelectedApiKey(tokenFromFile); setSelectedApiKey(tokenFromFile);
} }
} }

View File

@@ -1,56 +1,80 @@
import { UPDATER_CONFIG } from "@/shared/constants/config"; import { UPDATER_CONFIG } from "@/shared/constants/config";
// Browser-local endpoint presets shared by every CLI tool card // Browser-local preset stores (endpoints, API keys) shared by every CLI tool card
const STORAGE_KEY = "9router.cliToolEndpointPresets"; function createStore({ storageKey, changeEvent, itemField, normalize = (v) => v, defaultName = (v) => v }) {
const CHANGE_EVENT = "9router:endpoint-presets-changed"; const read = () => {
const stripSlash = (url) => (url || "").replace(/\/+$/, "");
export function readPresets() {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
try { try {
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); const raw = JSON.parse(window.localStorage.getItem(storageKey) || "[]");
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw.filter((p) => p?.name && p?.baseUrl); return raw.filter((p) => p?.name && p?.[itemField]);
} catch { } catch {
return []; return [];
} }
} };
function writePresets(presets) { const write = (items) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); window.localStorage.setItem(storageKey, JSON.stringify(items));
window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); window.dispatchEvent(new CustomEvent(changeEvent));
} };
export function subscribePresets(handler) { return {
read,
subscribe: (handler) => {
if (typeof window === "undefined") return () => {}; if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, handler); window.addEventListener(changeEvent, handler);
return () => window.removeEventListener(CHANGE_EVENT, handler); return () => window.removeEventListener(changeEvent, handler);
} },
// Adds or replaces a preset; returns the stored name, or null when skipped
upsert: (value, name) => {
const v = normalize(value);
if (!v) return null;
function defaultNameFor(url) { const items = read();
try { return new URL(url).host; } catch { return url; } const existing = items.find((p) => normalize(p[itemField]) === v);
}
// Adds or replaces a preset; returns the stored name, or null when skipped
export function upsertPreset(baseUrl, name) {
const url = stripSlash(baseUrl);
if (!url) return null;
const presets = readPresets();
const existing = presets.find((p) => stripSlash(p.baseUrl) === url);
if (existing && !name) return existing.name; if (existing && !name) return existing.name;
const finalName = (name || defaultNameFor(url)).trim(); const finalName = (name || defaultName(v)).trim();
if (!finalName) return null; if (!finalName) return null;
const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }] const next = [...items.filter((p) => p.name !== finalName && normalize(p[itemField]) !== v), { name: finalName, [itemField]: v }]
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
writePresets(next); write(next);
return finalName; return finalName;
},
remove: (name) => write(read().filter((p) => p.name !== name)),
};
} }
const stripSlash = (url) => (url || "").replace(/\/+$/, "");
const endpoints = createStore({
storageKey: "9router.cliToolEndpointPresets",
changeEvent: "9router:endpoint-presets-changed",
itemField: "baseUrl",
normalize: stripSlash,
defaultName: (url) => {
try { return new URL(url).host; } catch { return url; }
},
});
const apiKeys = createStore({
storageKey: "9router.cliToolApiKeyPresets",
changeEvent: "9router:api-key-presets-changed",
itemField: "key",
});
export const readPresets = endpoints.read;
export const subscribePresets = endpoints.subscribe;
export const upsertPreset = endpoints.upsert;
export const deletePreset = endpoints.remove;
export const readKeyPresets = apiKeys.read;
export const subscribeKeyPresets = apiKeys.subscribe;
export const upsertKeyPreset = apiKeys.upsert;
export const deleteKeyPreset = apiKeys.remove;
// Save an applied endpoint unless it exactly matches a built-in dropdown option // Save an applied endpoint unless it exactly matches a built-in dropdown option
export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) { export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) {
const url = stripSlash(baseUrl); const url = stripSlash(baseUrl);
@@ -64,8 +88,4 @@ export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloud
return upsertPreset(url); return upsertPreset(url);
} }
export function deletePreset(name) {
writePresets(readPresets().filter((p) => p.name !== name));
}
export { stripSlash }; export { stripSlash };