feat(cli-tools): support saving and managing custom API key presets
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user