feat: revise default key management and mask the default key on the home page

- Allow editing and deleting the default key
- Warn when deleting the last remaining key
- Mask api key by default and reveal it on click
This commit is contained in:
lzt404 committed 2026-07-24 20:09:02 +08:00
1 parent 122f6c438c
commit 6018e9d8a2
6 files changed
+360 -115

No files matched your search

+133 -53
View File
@@ -67,7 +67,7 @@ const MIN_MAIN_WINDOW_HEIGHT: u32 = 600;
const MAX_SAVED_WINDOW_DIMENSION: u32 = 16_384;
const OAUTH_DIR_NAME: &str = "oauth";
const DEFAULT_API_KEY: &str = "123456";
const DEFAULT_API_KEY_REMARK: &str = "内置密钥";
const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥";
const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456";
const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
@@ -465,7 +465,7 @@ impl Default for GuiConfigFile {
.and_then(|path| path.parent().map(|parent| parent.join(OAUTH_DIR_NAME)))
.map(|path| path_to_string(&path))
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string()),
api_keys: vec![built_in_api_key_entry()],
api_keys: vec![default_api_key_entry()],
// Keep plaintext here for management API auth. Core hashes the
// value written into config.yaml on startup.
management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -734,7 +734,6 @@ struct CoreConfigSettings {
struct CoreApiKeyView {
api_key: String,
remark: String,
built_in: bool,
}
#[derive(Clone, Serialize)]
@@ -1166,7 +1165,6 @@ impl From<&GuiConfigFile> for CoreConfigView {
.map(|entry| CoreApiKeyView {
api_key: entry.key.clone(),
remark: entry.remark.clone(),
built_in: entry.key == DEFAULT_API_KEY,
})
.collect(),
management_secret_configured: !config.management_secret_key.is_empty(),
@@ -4753,17 +4751,13 @@ fn add_core_api_key(
validate_core_api_key(&api_key)?;
validate_api_key_remark(&remark)?;
let mut settings = current_core_config_settings(gui_config_state.inner())?;
if api_key == DEFAULT_API_KEY
|| settings
.api_keys
.iter()
.any(|existing| existing == &api_key)
if settings
.api_keys
.iter()
.any(|existing| existing == &api_key)
{
return Err("该鉴权密钥已经存在".to_string());
}
if !settings.api_keys.iter().any(|key| key == DEFAULT_API_KEY) {
settings.api_keys.insert(0, DEFAULT_API_KEY.to_string());
}
settings.api_keys.push(api_key);
patch_core_api_keys(&settings.api_keys)?;
let added_api_key = settings.api_keys.last().map(|key| GuiApiKeyEntry {
@@ -4774,6 +4768,64 @@ fn add_core_api_key(
Ok(CoreConfigView::from(&config))
}
fn replace_core_api_key_value(
api_keys: &mut [String],
original_api_key: &str,
replacement_api_key: String,
) -> Result<(), String> {
let index = api_keys
.iter()
.position(|existing| existing == original_api_key)
.ok_or_else(|| "要编辑的鉴权密钥不存在,请刷新后重试".to_string())?;
if replacement_api_key != original_api_key
&& api_keys
.iter()
.any(|existing| existing == &replacement_api_key)
{
return Err("该鉴权密钥已经存在".to_string());
}
api_keys[index] = replacement_api_key;
Ok(())
}
fn remove_core_api_key_value(api_keys: &mut Vec<String>, api_key: &str) -> Result<(), String> {
let index = api_keys
.iter()
.position(|existing| existing == api_key)
.ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?;
api_keys.remove(index);
Ok(())
}
#[tauri::command]
fn update_core_api_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
original_api_key: String,
api_key: String,
remark: String,
) -> Result<CoreConfigView, String> {
let original_api_key = original_api_key.trim();
let api_key = api_key.trim().to_string();
let remark = remark.trim().to_string();
if original_api_key.is_empty() {
return Err("要编辑的鉴权密钥不能为空".to_string());
}
validate_core_api_key(&api_key)?;
validate_api_key_remark(&remark)?;
let mut settings = current_core_config_settings(gui_config_state.inner())?;
replace_core_api_key_value(&mut settings.api_keys, original_api_key, api_key.clone())?;
patch_core_api_keys(&settings.api_keys)?;
let config = gui_config_state.sync_core_settings_with_api_key(
&settings,
Some(GuiApiKeyEntry {
key: api_key,
remark,
}),
)?;
Ok(CoreConfigView::from(&config))
}
#[tauri::command]
fn delete_core_api_key(
gui_config_state: tauri::State<'_, GuiConfigState>,
@@ -4783,16 +4835,8 @@ fn delete_core_api_key(
if api_key.is_empty() {
return Err("要删除的鉴权密钥不能为空".to_string());
}
if api_key == DEFAULT_API_KEY {
return Err("内置密钥不能删除".to_string());
}
let mut settings = current_core_config_settings(gui_config_state.inner())?;
let index = settings
.api_keys
.iter()
.position(|existing| existing == api_key)
.ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?;
settings.api_keys.remove(index);
remove_core_api_key_value(&mut settings.api_keys, api_key)?;
patch_core_api_keys(&settings.api_keys)?;
let config = gui_config_state.sync_core_settings(&settings)?;
Ok(CoreConfigView::from(&config))
@@ -8625,12 +8669,19 @@ fn ensure_fixed_oauth_dir() -> Result<PathBuf, String> {
Ok(directory)
}
fn should_import_core_api_keys(had_existing_gui_config: bool, core_api_keys: &[String]) -> bool {
had_existing_gui_config || !core_api_keys.is_empty()
}
fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
ensure_fixed_oauth_dir()?;
let config_path = gui_config_path()?;
let legacy_config_path = legacy_gui_config_path()?;
let gui_config_exists = config_path.is_file();
let legacy_config_exists = legacy_config_path.is_file();
let had_existing_gui_config = gui_config_exists || legacy_config_exists;
let (mut config, presence, mut changed) = if config_path.is_file() {
let (mut config, presence, mut changed) = if gui_config_exists {
let content = fs::read_to_string(&config_path)
.map_err(|err| format!("读取 GUI 配置失败 {}: {err}", path_to_string(&config_path)))?;
let config = toml::from_str::<GuiConfigFile>(&content)
@@ -8638,7 +8689,7 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
let presence = toml::from_str::<GuiConfigPresence>(&content)
.map_err(|err| format!("解析 GUI 配置字段失败: {err}"))?;
(config, presence, false)
} else if legacy_config_path.is_file() {
} else if legacy_config_exists {
let content = fs::read_to_string(&legacy_config_path).map_err(|err| {
format!(
"读取旧 GUI 配置失败 {}: {err}",
@@ -8660,7 +8711,9 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|| presence.routing_strategy.is_none();
if missing_core_settings {
if let Ok(core_settings) = read_installed_core_config_settings() {
if presence.api_keys.is_none() {
if presence.api_keys.is_none()
&& should_import_core_api_keys(had_existing_gui_config, &core_settings.api_keys)
{
config.api_keys = merge_core_api_keys_with_gui_metadata(
&config.api_keys,
&core_settings.api_keys,
@@ -8704,10 +8757,10 @@ fn apply_core_settings_to_gui_config(
config.routing_strategy = core_settings.routing_strategy.clone();
}
fn built_in_api_key_entry() -> GuiApiKeyEntry {
fn default_api_key_entry() -> GuiApiKeyEntry {
GuiApiKeyEntry {
key: DEFAULT_API_KEY.to_string(),
remark: DEFAULT_API_KEY_REMARK.to_string(),
remark: DEFAULT_API_KEY_INITIAL_REMARK.to_string(),
}
}
@@ -8720,11 +8773,11 @@ fn merge_core_api_keys_with_gui_metadata(
core_api_keys: &[String],
added_api_key: Option<&GuiApiKeyEntry>,
) -> Vec<GuiApiKeyEntry> {
let mut merged = vec![built_in_api_key_entry()];
let mut merged: Vec<GuiApiKeyEntry> = Vec::new();
for api_key in core_api_keys {
let api_key = api_key.trim();
if api_key.is_empty() || api_key == DEFAULT_API_KEY || is_example_core_api_key(api_key) {
if api_key.is_empty() || is_example_core_api_key(api_key) {
continue;
}
if merged.iter().any(|entry| entry.key == api_key) {
@@ -8885,9 +8938,6 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
for entry in &mut config.api_keys {
entry.key = entry.key.trim().to_string();
entry.remark = entry.remark.trim().to_string();
if entry.key == DEFAULT_API_KEY {
entry.remark = DEFAULT_API_KEY_REMARK.to_string();
}
}
if config.api_keys != original_api_keys {
changed = true;
@@ -10828,6 +10878,7 @@ fn main() {
save_gui_settings,
get_core_config_settings,
add_core_api_key,
update_core_api_key,
delete_core_api_key,
set_core_management_secret_key,
clear_core_management_secret_key,
@@ -10948,7 +10999,7 @@ mod tests {
assert!(content.contains("auth-dir = "));
assert!(content.contains("[[api-keys]]"));
assert!(content.contains("key = \"123456\""));
assert!(content.contains("remark = \"内置密钥\""));
assert!(content.contains("remark = \"默认密钥\""));
assert!(content.contains("management-secret-key = \"123456\""));
assert!(content.contains("plugins-enabled = false"));
assert!(content.contains("routing-strategy = \"round-robin\""));
@@ -11998,7 +12049,7 @@ mod tests {
}
#[test]
fn legacy_string_api_keys_gain_remarks_and_keep_custom_keys() {
fn legacy_string_api_keys_keep_custom_keys_without_special_protection() {
let legacy = "port = 8317\nallow-lan = false\nrun-on-startup = false\nauth-dir = \"/tmp/oauth\"\napi-keys = [\"123456\", \"custom-key\"]\nmanagement-secret-key = \"123456\"\nplugins-enabled = false\nrouting-strategy = \"round-robin\"\n";
let mut config = toml::from_str::<GuiConfigFile>(legacy).unwrap();
@@ -12007,12 +12058,11 @@ mod tests {
gui_api_key_values(&config.api_keys),
vec!["123456", "custom-key"]
);
assert_eq!(config.api_keys[0].remark, DEFAULT_API_KEY_REMARK);
assert!(config.api_keys[0].remark.is_empty());
assert!(config.api_keys[1].remark.is_empty());
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(serialized.contains("[[api-keys]]"));
assert!(serialized.contains("remark = \"内置密钥\""));
let reparsed = toml::from_str::<GuiConfigFile>(&serialized).unwrap();
assert_eq!(reparsed.api_keys, config.api_keys);
}
@@ -12020,7 +12070,7 @@ mod tests {
#[test]
fn api_key_remarks_follow_matching_core_keys() {
let existing = vec![
built_in_api_key_entry(),
default_api_key_entry(),
GuiApiKeyEntry {
key: "custom-key".to_string(),
remark: "开发环境".to_string(),
@@ -12030,12 +12080,48 @@ mod tests {
let merged = merge_core_api_keys_with_gui_metadata(&existing, &core_keys, None);
assert_eq!(
gui_api_key_values(&merged),
vec!["123456", "custom-key", "new-key"]
);
assert_eq!(merged[1].remark, "开发环境");
assert!(merged[2].remark.is_empty());
assert_eq!(gui_api_key_values(&merged), vec!["custom-key", "new-key"]);
assert_eq!(merged[0].remark, "开发环境");
assert!(merged[1].remark.is_empty());
}
#[test]
fn explicit_empty_api_key_list_stays_empty() {
let existing = vec![default_api_key_entry()];
assert!(merge_core_api_keys_with_gui_metadata(&existing, &[], None).is_empty());
let mut config = GuiConfigFile {
api_keys: Vec::new(),
..GuiConfigFile::default()
};
sanitize_gui_config(&mut config).unwrap();
assert!(config.api_keys.is_empty());
let content = toml::to_string_pretty(&config).unwrap();
let restored = toml::from_str::<GuiConfigFile>(&content).unwrap();
assert!(restored.api_keys.is_empty());
}
#[test]
fn fresh_config_keeps_initial_default_unless_core_has_real_keys() {
assert!(!should_import_core_api_keys(false, &[]));
assert!(should_import_core_api_keys(
false,
&["existing-key".to_string()]
));
assert!(should_import_core_api_keys(true, &[]));
}
#[test]
fn initial_default_api_key_can_be_edited_and_deleted() {
let mut api_keys = vec![DEFAULT_API_KEY.to_string()];
replace_core_api_key_value(&mut api_keys, DEFAULT_API_KEY, "custom-key".to_string())
.unwrap();
assert_eq!(api_keys, vec!["custom-key"]);
remove_core_api_key_value(&mut api_keys, "custom-key").unwrap();
assert!(api_keys.is_empty());
}
#[test]
@@ -12043,8 +12129,8 @@ mod tests {
let view = serde_json::to_value(CoreConfigView::from(&GuiConfigFile::default())).unwrap();
assert_eq!(view["apiKeys"][0]["apiKey"], DEFAULT_API_KEY);
assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_REMARK);
assert_eq!(view["apiKeys"][0]["builtIn"], true);
assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_INITIAL_REMARK);
assert!(view["apiKeys"][0].get("builtIn").is_none());
}
#[test]
@@ -12101,10 +12187,7 @@ mod tests {
assert!(presence.routing_strategy.is_none());
apply_core_settings_to_gui_config(&mut config, &core_settings);
assert_eq!(
gui_api_key_values(&config.api_keys),
vec!["123456", "existing-key"]
);
assert_eq!(gui_api_key_values(&config.api_keys), vec!["existing-key"]);
assert_eq!(config.management_secret_key, DEFAULT_MANAGEMENT_SECRET_KEY);
assert!(config.plugins_enabled);
assert_eq!(config.routing_strategy, "fill-first");
@@ -12121,10 +12204,7 @@ mod tests {
apply_core_settings_to_gui_config(&mut config, &core_settings);
assert_eq!(core_settings.api_keys, vec!["real-key"]);
assert_eq!(
gui_api_key_values(&config.api_keys),
vec!["123456", "real-key"]
);
assert_eq!(gui_api_key_values(&config.api_keys), vec!["real-key"]);
assert_eq!(
core_settings.management_secret_key.as_deref(),
Some("plain-management-secret")
@@ -12472,7 +12552,7 @@ mod tests {
window_height: None,
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
api_keys: vec![
built_in_api_key_entry(),
default_api_key_entry(),
GuiApiKeyEntry {
key: "gui-key".to_string(),
remark: "测试密钥".to_string(),
+6 -2
View File
@@ -221,6 +221,7 @@ export const jaOverrides = {
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
'config.notice.keyAdded': '認証キーを追加しました',
'config.notice.keyUpdated': '認証キーを更新しました',
'config.notice.keyDeleted': '認証キーを削除しました',
'config.notice.keyCopied': '認証キーをコピーしました',
'config.notice.keyCopyFailed': '認証キーをコピーできませんでした',
@@ -235,8 +236,8 @@ export const jaOverrides = {
'config.keys.noRemark': 'メモなし',
'config.keys.copy': '認証キーをコピー',
'config.keys.copyNth': '{number} 番目の認証キーをコピー',
'config.keys.builtInProtected': '組み込みキーは削除できません',
'config.keys.builtIn': '組み込み',
'config.keys.edit': '認証キーを編集',
'config.keys.editNth': '{number} 番目の認証キーを編集',
'config.keys.delete': '認証キーを削除',
'config.keys.deleteNth': '{number} 番目の認証キーを削除',
'config.keys.empty': '認証キーがありません',
@@ -248,6 +249,7 @@ export const jaOverrides = {
'config.plugins.enable': 'プラグインシステムを有効にする',
'config.routing.title': 'ルーティング方式',
'config.keys.addTitle': '認証キーを追加',
'config.keys.editTitle': '認証キーを編集',
'common.close': '閉じる',
'config.keys.label': '認証キー',
'config.keys.hide': 'キーを隠す',
@@ -258,6 +260,7 @@ export const jaOverrides = {
'config.keys.adding': '追加中',
'common.add': '追加',
'config.keys.deleteTitle': '認証キーを削除',
'config.keys.deleteAllWarning': 'すべてのキーを削除すると、デフォルトですべてのキーが許可されます',
'common.cancel': 'キャンセル',
'common.deleting': '削除中',
'common.delete': '削除',
@@ -633,6 +636,7 @@ export const jaOverrides = {
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
'kernel.versions.offlineInstall': 'オフラインインストール',
'kernel.access.defaultKey': '既定のキー:{key}',
'kernel.access.noConfiguredKey': 'キーが設定されていないため、現在はすべてのキーがデフォルトで許可されます',
'kernel.access.connectable': '接続可能',
'kernel.access.waiting': 'コアの起動を待っています',
'kernel.access.localUrl': 'ローカル URL',
+12 -4
View File
@@ -224,6 +224,7 @@ export const zhCN = {
'config.error.duplicateKey': '该鉴权密钥已经存在',
'config.error.remarkTooLong': '备注不能超过 80 个字符',
'config.notice.keyAdded': '鉴权密钥已新增',
'config.notice.keyUpdated': '鉴权密钥已更新',
'config.notice.keyDeleted': '鉴权密钥已删除',
'config.notice.keyCopied': '鉴权密钥已复制',
'config.notice.keyCopyFailed': '复制鉴权密钥失败',
@@ -238,8 +239,8 @@ export const zhCN = {
'config.keys.noRemark': '未填写备注',
'config.keys.copy': '复制鉴权密钥',
'config.keys.copyNth': '复制第 {number} 个鉴权密钥',
'config.keys.builtInProtected': '内置密钥不能删除',
'config.keys.builtIn': '内置',
'config.keys.edit': '编辑鉴权密钥',
'config.keys.editNth': '编辑第 {number} 个鉴权密钥',
'config.keys.delete': '删除鉴权密钥',
'config.keys.deleteNth': '删除第 {number} 个鉴权密钥',
'config.keys.empty': '暂无鉴权密钥',
@@ -251,6 +252,7 @@ export const zhCN = {
'config.plugins.enable': '启用插件系统',
'config.routing.title': '路由策略',
'config.keys.addTitle': '新增鉴权密钥',
'config.keys.editTitle': '编辑鉴权密钥',
'common.close': '关闭',
'config.keys.label': '鉴权密钥',
'config.keys.hide': '隐藏密钥',
@@ -261,6 +263,7 @@ export const zhCN = {
'config.keys.adding': '正在新增',
'common.add': '新增',
'config.keys.deleteTitle': '删除鉴权密钥',
'config.keys.deleteAllWarning': '删除所有密钥将会导致默认放行所有密钥',
'common.cancel': '取消',
'common.deleting': '正在删除',
'common.delete': '删除',
@@ -636,6 +639,7 @@ export const zhCN = {
'kernel.versions.noBundled': '当前发行包未包含内置内核',
'kernel.versions.offlineInstall': '离线安装',
'kernel.access.defaultKey': '默认密钥:{key}',
'kernel.access.noConfiguredKey': '未设置密钥,当前默认放行所有密钥',
'kernel.access.connectable': '可连接',
'kernel.access.waiting': '等待内核启动',
'kernel.access.localUrl': '本机 URL',
@@ -927,6 +931,7 @@ export const en: Record<MessageKey, string> = {
'config.error.duplicateKey': 'This authentication key already exists',
'config.error.remarkTooLong': 'The remark cannot exceed 80 characters',
'config.notice.keyAdded': 'Authentication key added',
'config.notice.keyUpdated': 'Authentication key updated',
'config.notice.keyDeleted': 'Authentication key deleted',
'config.notice.keyCopied': 'Authentication key copied',
'config.notice.keyCopyFailed': 'Failed to copy the authentication key',
@@ -941,8 +946,8 @@ export const en: Record<MessageKey, string> = {
'config.keys.noRemark': 'No remark',
'config.keys.copy': 'Copy authentication key',
'config.keys.copyNth': 'Copy authentication key {number}',
'config.keys.builtInProtected': 'The built-in key cannot be deleted',
'config.keys.builtIn': 'Built-in',
'config.keys.edit': 'Edit authentication key',
'config.keys.editNth': 'Edit authentication key {number}',
'config.keys.delete': 'Delete authentication key',
'config.keys.deleteNth': 'Delete authentication key {number}',
'config.keys.empty': 'No authentication keys',
@@ -954,6 +959,7 @@ export const en: Record<MessageKey, string> = {
'config.plugins.enable': 'Enable plugin system',
'config.routing.title': 'Routing Strategy',
'config.keys.addTitle': 'Add Authentication Key',
'config.keys.editTitle': 'Edit Authentication Key',
'common.close': 'Close',
'config.keys.label': 'Authentication Key',
'config.keys.hide': 'Hide key',
@@ -964,6 +970,7 @@ export const en: Record<MessageKey, string> = {
'config.keys.adding': 'Adding',
'common.add': 'Add',
'config.keys.deleteTitle': 'Delete Authentication Key',
'config.keys.deleteAllWarning': 'Deleting all keys will allow all keys by default',
'common.cancel': 'Cancel',
'common.deleting': 'Deleting',
'common.delete': 'Delete',
@@ -1339,6 +1346,7 @@ export const en: Record<MessageKey, string> = {
'kernel.versions.noBundled': 'This release does not include a bundled core',
'kernel.versions.offlineInstall': 'Offline Install',
'kernel.access.defaultKey': 'Default key: {key}',
'kernel.access.noConfiguredKey': 'No key is configured; all keys are currently allowed by default',
'kernel.access.connectable': 'Connectable',
'kernel.access.waiting': 'Waiting for core startup',
'kernel.access.localUrl': 'Local URL',
+64 -34
View File
@@ -7,6 +7,7 @@ import {
Eye,
EyeOff,
KeyRound,
Pencil,
Plus,
Plug,
RefreshCw,
@@ -26,10 +27,9 @@ type CoreConfigSettings = {
type CoreApiKey = {
apiKey: string;
remark: string;
builtIn: boolean;
};
type ConfigAction = 'add-key' | 'delete-key' | 'plugins' | 'routing' | null;
type ConfigAction = 'add-key' | 'update-key' | 'delete-key' | 'plugins' | 'routing' | null;
type NoticeTone = 'success' | 'error';
const ROUTING_OPTIONS = [
@@ -44,6 +44,7 @@ export function ConfigPanelPage() {
const [loadError, setLoadError] = useState('');
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editingApiKey, setEditingApiKey] = useState<string | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [newApiKey, setNewApiKey] = useState('');
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
@@ -114,6 +115,7 @@ export function ConfigPanelPage() {
};
const openAddDialog = () => {
setEditingApiKey(null);
setNewApiKey('');
setNewApiKeyRemark('');
setShowApiKey(false);
@@ -121,11 +123,21 @@ export function ConfigPanelPage() {
setAddDialogOpen(true);
};
const openEditDialog = (entry: CoreApiKey) => {
setEditingApiKey(entry.apiKey);
setNewApiKey(entry.apiKey);
setNewApiKeyRemark(entry.remark);
setShowApiKey(false);
setFormError('');
setAddDialogOpen(true);
};
const closeAddDialog = () => {
if (busyAction === 'add-key') {
if (busyAction === 'add-key' || busyAction === 'update-key') {
return;
}
setAddDialogOpen(false);
setEditingApiKey(null);
setFormError('');
};
@@ -149,7 +161,7 @@ export function ConfigPanelPage() {
setFormError(t('config.error.invalidKey'));
return;
}
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey)) {
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey && entry.apiKey !== editingApiKey)) {
setFormError(t('config.error.duplicateKey'));
return;
}
@@ -159,14 +171,16 @@ export function ConfigPanelPage() {
return;
}
const editing = editingApiKey !== null;
const saved = await runMutation(
'add-key',
'add_core_api_key',
{ apiKey, remark },
t('config.notice.keyAdded'),
editing ? 'update-key' : 'add-key',
editing ? 'update_core_api_key' : 'add_core_api_key',
editing ? { originalApiKey: editingApiKey, apiKey, remark } : { apiKey, remark },
editing ? t('config.notice.keyUpdated') : t('config.notice.keyAdded'),
);
if (saved) {
setAddDialogOpen(false);
setEditingApiKey(null);
setNewApiKey('');
setNewApiKeyRemark('');
}
@@ -228,6 +242,8 @@ export function ConfigPanelPage() {
const controlsDisabled = loading || settings === null || busyAction !== null;
const selectedDeleteKey =
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
return (
<section className="page config-page">
@@ -302,22 +318,26 @@ export function ConfigPanelPage() {
<Copy size={16} aria-hidden="true" />
)}
</button>
{entry.builtIn ? (
<span className="config-protected-key" title={t('config.keys.builtInProtected')}>
{t('config.keys.builtIn')}
</span>
) : (
<button
type="button"
className="icon-button danger"
onClick={() => setDeleteIndex(index)}
disabled={controlsDisabled}
title={t('config.keys.delete')}
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
>
<Trash2 size={16} aria-hidden="true" />
</button>
)}
<button
type="button"
className="icon-button quiet"
onClick={() => openEditDialog(entry)}
disabled={controlsDisabled}
title={t('config.keys.edit')}
aria-label={t('config.keys.editNth', { number: index + 1 })}
>
<Pencil size={16} aria-hidden="true" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => setDeleteIndex(index)}
disabled={controlsDisabled}
title={t('config.keys.delete')}
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
</div>
))
@@ -409,13 +429,15 @@ export function ConfigPanelPage() {
<div className="config-dialog-heading">
<div>
<KeyRound size={19} aria-hidden="true" />
<h2 id="add-api-key-title">{t('config.keys.addTitle')}</h2>
<h2 id="add-api-key-title">
{editingApiKey === null ? t('config.keys.addTitle') : t('config.keys.editTitle')}
</h2>
</div>
<button
type="button"
className="icon-button quiet"
onClick={closeAddDialog}
disabled={busyAction === 'add-key'}
disabled={keyMutationBusy}
title={t('common.close')}
aria-label={t('common.close')}
>
@@ -434,7 +456,7 @@ export function ConfigPanelPage() {
setNewApiKey(event.currentTarget.value);
setFormError('');
}}
disabled={busyAction === 'add-key'}
disabled={keyMutationBusy}
aria-invalid={Boolean(formError)}
placeholder="sk-..."
/>
@@ -442,7 +464,7 @@ export function ConfigPanelPage() {
type="button"
className="icon-button quiet"
onClick={() => setShowApiKey((visible) => !visible)}
disabled={busyAction === 'add-key'}
disabled={keyMutationBusy}
title={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
aria-label={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
>
@@ -466,7 +488,7 @@ export function ConfigPanelPage() {
setNewApiKeyRemark(event.currentTarget.value);
setFormError('');
}}
disabled={busyAction === 'add-key'}
disabled={keyMutationBusy}
placeholder={t('config.keys.remarkPlaceholder')}
/>
</label>
@@ -480,14 +502,16 @@ export function ConfigPanelPage() {
type="button"
className="secondary-button"
onClick={generateApiKey}
disabled={busyAction === 'add-key'}
disabled={keyMutationBusy}
>
<Sparkles size={16} aria-hidden="true" />
{t('config.keys.generate')}
</button>
<button type="submit" className="primary-button" disabled={busyAction === 'add-key'}>
<Plus size={16} aria-hidden="true" />
{busyAction === 'add-key' ? t('config.keys.adding') : t('common.add')}
<button type="submit" className="primary-button" disabled={keyMutationBusy}>
{editingApiKey === null ? <Plus size={16} aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
{keyMutationBusy
? editingApiKey === null ? t('config.keys.adding') : t('common.saving')
: editingApiKey === null ? t('common.add') : t('common.save')}
</button>
</div>
</form>
@@ -501,7 +525,7 @@ export function ConfigPanelPage() {
}
}}>
<div
className="config-dialog config-delete-dialog"
className={`config-dialog config-delete-dialog ${deletingLastKey ? 'has-warning' : ''}`}
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-api-key-title"
@@ -513,6 +537,12 @@ export function ConfigPanelPage() {
</div>
</div>
<code className="config-delete-key">{maskApiKey(selectedDeleteKey)}</code>
{deletingLastKey ? (
<div className="config-delete-warning">
<AlertCircle size={17} aria-hidden="true" />
<span>{t('config.keys.deleteAllWarning')}</span>
</div>
) : null}
<div className="config-dialog-actions two-actions">
<button
type="button"
+62 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { AlertCircle, Check, Copy, ExternalLink, Info } from 'lucide-react';
import { AlertCircle, Check, Copy, ExternalLink, Eye, EyeOff, Info } from 'lucide-react';
import { type CoreStatus, useCoreRuntime } from '../coreRuntime';
import openaiIcon from '../assets/icons/openai-light.svg';
import claudeIcon from '../assets/icons/claude.svg';
@@ -57,6 +57,10 @@ type GuiSettings = {
runOnStartup: boolean;
};
type CoreConfigSummary = {
apiKeys: Array<{ apiKey: string }>;
};
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
let latestAutoCheckStarted = false;
@@ -134,6 +138,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [cancellingInstall, setCancellingInstall] = useState(false);
const [copiedApiField, setCopiedApiField] = useState('');
const [homeApiKey, setHomeApiKey] = useState<string | null>(DEFAULT_CLIENT_API_KEY);
const [showHomeApiKey, setShowHomeApiKey] = useState(false);
const [lanIpv4, setLanIpv4] = useState<string | null>(null);
const [lanIpChecked, setLanIpChecked] = useState(false);
const installDialogRef = useRef<HTMLDivElement>(null);
@@ -213,6 +219,9 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
loadBundledCore();
loadInstallTask();
loadGuiSettings();
if (view === 'home') {
void loadHomeApiKey();
}
if (!latestAutoCheckStarted) {
latestAutoCheckStarted = true;
@@ -334,6 +343,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
}
};
const loadHomeApiKey = async () => {
try {
const settings = await invoke<CoreConfigSummary>('get_core_config_settings');
setHomeApiKey(settings.apiKeys[0]?.apiKey ?? null);
} catch {
setHomeApiKey(DEFAULT_CLIENT_API_KEY);
}
};
const saveNetworkSettings = async (
allowLan: boolean,
port: number,
@@ -969,7 +987,49 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<div className="panel-heading client-api-heading">
<div>
<h2>API URL</h2>
<p>{t('kernel.access.defaultKey', { key: DEFAULT_CLIENT_API_KEY })}</p>
{homeApiKey ? (
<div className="client-api-key-actions">
<button
type="button"
className="client-api-default-key"
aria-pressed={showHomeApiKey}
title={showHomeApiKey ? t('config.keys.hide') : t('config.keys.show')}
onClick={() => setShowHomeApiKey((visible) => !visible)}
>
<span>
{t('kernel.access.defaultKey', {
key: showHomeApiKey ? homeApiKey : '••••••',
})}
</span>
{showHomeApiKey ? (
<EyeOff size={14} aria-hidden="true" />
) : (
<Eye size={14} aria-hidden="true" />
)}
</button>
<button
type="button"
className="icon-button quiet client-api-key-copy"
title={t('config.keys.copy')}
aria-label={t('config.keys.copy')}
onClick={() =>
void copyApiValue(
homeApiKey,
'home:api-key',
t('config.notice.keyCopied'),
)
}
>
{copiedApiField === 'home:api-key' ? (
<Check size={14} aria-hidden="true" />
) : (
<Copy size={14} aria-hidden="true" />
)}
</button>
</div>
) : (
<p className="client-api-no-key">{t('kernel.access.noConfiguredKey')}</p>
)}
</div>
<span className={`state-pill ${coreRunning ? 'success' : ''}`}>
{coreRunning ? t('kernel.access.connectable') : t('kernel.access.waiting')}
+83 -20
View File
@@ -636,6 +636,63 @@ input:focus-visible {
flex: none;
}
.client-api-key-actions {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
margin: 4px 0 0;
}
.client-api-default-key {
display: inline-flex;
min-width: 0;
min-height: 19px;
flex: 0 1 auto;
align-items: center;
gap: 6px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #5f5a53;
font-size: 13px;
line-height: 1.45;
}
.client-api-default-key span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-api-default-key svg {
flex: none;
color: #7a746c;
}
.client-api-default-key:hover span {
color: #2d2a26;
text-decoration: underline;
text-underline-offset: 2px;
}
.client-api-key-copy {
width: 26px;
height: 26px;
border-color: transparent !important;
background: transparent !important;
}
.client-api-key-copy:hover:not(:disabled) {
border-color: #d5d2cb !important;
background: #f4f1ea !important;
}
.client-api-no-key {
color: #9f2d20 !important;
}
.client-api-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
@@ -1247,7 +1304,7 @@ input:focus-visible {
.config-key-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: center;
gap: 12px;
min-height: 66px;
@@ -1301,24 +1358,6 @@ input:focus-visible {
white-space: nowrap;
}
.config-protected-key {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
border: 1px solid #c9c4ba;
border-radius: 999px;
background: #f4f1ea;
color: #6d6760;
font-size: 10px;
font-weight: 800;
}
.config-protected-key {
width: 40px;
height: 28px;
}
.config-key-details code {
min-width: 0;
overflow: hidden;
@@ -1331,7 +1370,7 @@ input:focus-visible {
.config-key-actions {
display: grid;
grid-template-columns: 32px 40px;
grid-template-columns: repeat(3, 32px);
justify-content: end;
gap: 8px;
}
@@ -1710,6 +1749,12 @@ input:focus-visible {
height: 202px;
}
.config-delete-dialog.has-warning {
grid-template-rows: 40px 52px auto 40px;
height: auto;
min-height: 254px;
}
.config-delete-key {
display: flex;
min-width: 0;
@@ -1725,6 +1770,24 @@ input:focus-visible {
white-space: nowrap;
}
.config-delete-warning {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 10px 12px;
border: 1px solid #e2b6ae;
border-radius: 6px;
background: #fff1ee;
color: #8f3025;
font-size: 12px;
line-height: 1.45;
}
.config-delete-warning svg {
margin-top: 1px;
}
.config-toast {
position: fixed;
right: 24px;