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}`}>
<select <div className="flex items-center gap-2">
value={mode} <select
onChange={handleSelect} value={mode}
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" onChange={handleSelect}
> 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) => ( >
<option key={k.id} value={k.key}>{k.key}</option> {options.map((o) => (
))} <option key={o.value} value={o.value}>{o.label}</option>
<option value={CUSTOM_VALUE}>Custom...</option> ))}
</select> {canSave && <option value={SAVE_VALUE}>+ Save current as...</option>}
{mode === CUSTOM_VALUE && ( </select>
{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,55 +1,79 @@
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 = () => {
if (typeof window === "undefined") return [];
try {
const raw = JSON.parse(window.localStorage.getItem(storageKey) || "[]");
if (!Array.isArray(raw)) return [];
return raw.filter((p) => p?.name && p?.[itemField]);
} catch {
return [];
}
};
const write = (items) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(storageKey, JSON.stringify(items));
window.dispatchEvent(new CustomEvent(changeEvent));
};
return {
read,
subscribe: (handler) => {
if (typeof window === "undefined") return () => {};
window.addEventListener(changeEvent, 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;
const items = read();
const existing = items.find((p) => normalize(p[itemField]) === v);
if (existing && !name) return existing.name;
const finalName = (name || defaultName(v)).trim();
if (!finalName) return null;
const next = [...items.filter((p) => p.name !== finalName && normalize(p[itemField]) !== v), { name: finalName, [itemField]: v }]
.sort((a, b) => a.name.localeCompare(b.name));
write(next);
return finalName;
},
remove: (name) => write(read().filter((p) => p.name !== name)),
};
}
const stripSlash = (url) => (url || "").replace(/\/+$/, ""); const stripSlash = (url) => (url || "").replace(/\/+$/, "");
export function readPresets() { const endpoints = createStore({
if (typeof window === "undefined") return []; storageKey: "9router.cliToolEndpointPresets",
try { changeEvent: "9router:endpoint-presets-changed",
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]"); itemField: "baseUrl",
if (!Array.isArray(raw)) return []; normalize: stripSlash,
return raw.filter((p) => p?.name && p?.baseUrl); defaultName: (url) => {
} catch { try { return new URL(url).host; } catch { return url; }
return []; },
} });
}
function writePresets(presets) { const apiKeys = createStore({
if (typeof window === "undefined") return; storageKey: "9router.cliToolApiKeyPresets",
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); changeEvent: "9router:api-key-presets-changed",
window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); itemField: "key",
} });
export function subscribePresets(handler) { export const readPresets = endpoints.read;
if (typeof window === "undefined") return () => {}; export const subscribePresets = endpoints.subscribe;
window.addEventListener(CHANGE_EVENT, handler); export const upsertPreset = endpoints.upsert;
return () => window.removeEventListener(CHANGE_EVENT, handler); export const deletePreset = endpoints.remove;
}
function defaultNameFor(url) { export const readKeyPresets = apiKeys.read;
try { return new URL(url).host; } catch { return url; } export const subscribeKeyPresets = apiKeys.subscribe;
} export const upsertKeyPreset = apiKeys.upsert;
export const deleteKeyPreset = apiKeys.remove;
// 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;
const finalName = (name || defaultNameFor(url)).trim();
if (!finalName) return null;
const next = [...presets.filter((p) => p.name !== finalName && stripSlash(p.baseUrl) !== url), { name: finalName, baseUrl: url }]
.sort((a, b) => a.name.localeCompare(b.name));
writePresets(next);
return finalName;
}
// 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 } = {}) {
@@ -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 };