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:
1 parent
122f6c438c
commit
6018e9d8a2
6 files changed
+360
-115
No files matched your search
+133
-53
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user