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";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { readKeyPresets, upsertKeyPreset, deleteKeyPreset, subscribeKeyPresets } from "./cliEndpointPresets";
const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save_key__";
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
const isCustom = !apiKeys.some((k) => k.key === value) && value !== "";
const [mode, setMode] = useState(() => {
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE;
if (apiKeys.some((k) => k.key === value)) return value;
return CUSTOM_VALUE;
});
const [customInput, setCustomInput] = useState(isCustom ? value : "");
const [savedKeys, setSavedKeys] = useState([]);
// Custom mode is sticky once the user types, so an emptied input doesn't jump back to a dropdown option
const [customMode, setCustomMode] = useState(false);
const [customInput, setCustomInput] = useState("");
useEffect(() => {
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 next = e.target.value;
setMode(next);
if (next === SAVE_VALUE) {
upsertKeyPreset((value || "").trim());
return;
}
if (next === CUSTOM_VALUE) {
setCustomMode(true);
setCustomInput("");
onChange("");
} else {
onChange(next);
return;
}
setCustomMode(false);
setCustomInput("");
const opt = options.find((o) => o.value === next);
if (opt) onChange(opt.url ?? opt.value);
};
const handleCustomInput = (e) => {
const v = e.target.value;
setCustomMode(true);
setCustomInput(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 (
<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)"}
@@ -42,20 +80,27 @@ export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabl
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
<select
value={mode}
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"
>
{apiKeys.map((k) => (
<option key={k.id} value={k.key}>{k.key}</option>
))}
<option value={CUSTOM_VALUE}>Custom...</option>
</select>
{mode === CUSTOM_VALUE && (
<div className="flex items-center gap-2">
<select
value={mode}
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"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{canSave && <option value={SAVE_VALUE}>+ Save current as...</option>}
</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
type="text"
value={customInput}
value={inputValue}
onChange={handleCustomInput}
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"

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;
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
if (tokenFromFile) {
setSelectedApiKey(tokenFromFile);
}
}

View File

@@ -1,55 +1,79 @@
import { UPDATER_CONFIG } from "@/shared/constants/config";
// Browser-local endpoint presets shared by every CLI tool card
const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CHANGE_EVENT = "9router:endpoint-presets-changed";
// Browser-local preset stores (endpoints, API keys) shared by every CLI tool card
function createStore({ storageKey, changeEvent, itemField, normalize = (v) => v, defaultName = (v) => v }) {
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(/\/+$/, "");
export function readPresets() {
if (typeof window === "undefined") return [];
try {
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
if (!Array.isArray(raw)) return [];
return raw.filter((p) => p?.name && p?.baseUrl);
} catch {
return [];
}
}
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; }
},
});
function writePresets(presets) {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
}
const apiKeys = createStore({
storageKey: "9router.cliToolApiKeyPresets",
changeEvent: "9router:api-key-presets-changed",
itemField: "key",
});
export function subscribePresets(handler) {
if (typeof window === "undefined") return () => {};
window.addEventListener(CHANGE_EVENT, handler);
return () => window.removeEventListener(CHANGE_EVENT, handler);
}
export const readPresets = endpoints.read;
export const subscribePresets = endpoints.subscribe;
export const upsertPreset = endpoints.upsert;
export const deletePreset = endpoints.remove;
function defaultNameFor(url) {
try { return new URL(url).host; } catch { return url; }
}
// 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;
}
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
export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloudUrl } = {}) {
@@ -64,8 +88,4 @@ export function rememberEndpoint(baseUrl, { tunnelPublicUrl, tailscaleUrl, cloud
return upsertPreset(url);
}
export function deletePreset(name) {
writePresets(readPresets().filter((p) => p.name !== name));
}
export { stripSlash };