Compare commits

...

3 Commits

Author SHA1 Message Date
voson
cf93488c6a release(secrets-mcp): 0.5.18 — Web 条目密文值编辑,PATCH /api/secrets/:id 支持 value
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:14:04 +08:00
137a4d42b0 release(secrets-mcp): 0.5.17 — 取消生产环境强制 PG TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m27s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 1m35s
移除 SECRETS_ENV=production 时对 verify-ca/verify-full 的硬性要求,
仍可通过 SECRETS_DATABASE_SSL_MODE 显式选择模式。

Made-with: Cursor
2026-04-10 17:10:55 +08:00
agent
ff2ea91e72 release(secrets-mcp): 0.5.16 — 回收站页面 title 国际化
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m32s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
2026-04-10 14:15:15 +08:00
7 changed files with 287 additions and 102 deletions

2
Cargo.lock generated
View File

@@ -2065,7 +2065,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.5.15" version = "0.5.18"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",

View File

@@ -8,7 +8,6 @@ pub struct DatabaseConfig {
pub url: String, pub url: String,
pub ssl_mode: Option<PgSslMode>, pub ssl_mode: Option<PgSslMode>,
pub ssl_root_cert: Option<PathBuf>, pub ssl_root_cert: Option<PathBuf>,
pub enforce_strict_tls: bool,
} }
/// Resolve database URL from environment. /// Resolve database URL from environment.
@@ -63,20 +62,10 @@ fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
Ok(Some(path)) Ok(Some(path))
} }
fn is_production_env() -> bool {
matches!(
env_var_non_empty("SECRETS_ENV")
.as_deref()
.map(|value| value.to_ascii_lowercase()),
Some(value) if value == "prod" || value == "production"
)
}
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> { pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
Ok(DatabaseConfig { Ok(DatabaseConfig {
url: resolve_db_url(override_url)?, url: resolve_db_url(override_url)?,
ssl_mode: parse_ssl_mode_from_env()?, ssl_mode: parse_ssl_mode_from_env()?,
ssl_root_cert: resolve_ssl_root_cert_from_env()?, ssl_root_cert: resolve_ssl_root_cert_from_env()?,
enforce_strict_tls: is_production_env(),
}) })
} }

View File

@@ -3,7 +3,7 @@ use std::str::FromStr;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use crate::config::DatabaseConfig; use crate::config::DatabaseConfig;
@@ -18,18 +18,6 @@ fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
options = options.ssl_root_cert(path); options = options.ssl_root_cert(path);
} }
if config.enforce_strict_tls
&& !matches!(
options.get_ssl_mode(),
PgSslMode::VerifyCa | PgSslMode::VerifyFull
)
{
anyhow::bail!(
"Refusing to start in production with weak PostgreSQL TLS mode. \
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
);
}
Ok(options) Ok(options)
} }

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.5.15" version = "0.5.18"
edition.workspace = true edition.workspace = true
[[bin]] [[bin]]

View File

@@ -138,6 +138,25 @@ pub(super) struct EntryOptionQuery {
type EntryApiError = (StatusCode, Json<serde_json::Value>); type EntryApiError = (StatusCode, Json<serde_json::Value>);
fn require_encryption_key(headers: &HeaderMap, lang: UiLang) -> Result<[u8; 32], EntryApiError> {
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})
}
fn map_entry_mutation_err(e: anyhow::Error, lang: UiLang) -> EntryApiError { fn map_entry_mutation_err(e: anyhow::Error, lang: UiLang) -> EntryApiError {
if let Some(app_err) = e.downcast_ref::<AppError>() { if let Some(app_err) = e.downcast_ref::<AppError>() {
return map_app_error(app_err, lang); return map_app_error(app_err, lang);
@@ -876,6 +895,7 @@ pub(super) struct SecretPatchBody {
name: Option<String>, name: Option<String>,
#[serde(rename = "type")] #[serde(rename = "type")]
secret_type: Option<String>, secret_type: Option<String>,
value: Option<serde_json::Value>,
} }
pub(super) async fn api_secret_patch( pub(super) async fn api_secret_patch(
@@ -901,6 +921,7 @@ pub(super) async fn api_secret_patch(
let name = body.name.as_ref().map(|s| s.trim()); let name = body.name.as_ref().map(|s| s.trim());
let secret_type = body.secret_type.as_ref().map(|s| s.trim()); let secret_type = body.secret_type.as_ref().map(|s| s.trim());
let secret_value = body.value.as_ref();
if let Some(n) = name { if let Some(n) = name {
if n.is_empty() { if n.is_empty() {
@@ -940,30 +961,37 @@ pub(super) async fn api_secret_patch(
} }
} }
if name.is_none() && secret_type.is_none() { if name.is_none() && secret_type.is_none() && secret_value.is_none() {
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json( Json(
json!({ "error": tr(lang, "至少需要提供 name 或 type 之一", "至少需要提供 nametype 之一", "At least one of name or type is required") }), json!({ "error": tr(lang, "至少需要提供 name、type 或 value 之一", "至少需要提供 nametype 或 value 之一", "At least one of name, type, or value is required") }),
), ),
)); ));
} }
let master_key = if secret_value.is_some() {
Some(require_encryption_key(&headers, lang)?)
} else {
None
};
let mut tx = state let mut tx = state
.pool .pool
.begin() .begin()
.await .await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?; .map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let secret_row: Option<(String, String)> = let secret_row: Option<(String, String, Vec<u8>)> = sqlx::query_as(
sqlx::query_as("SELECT name, type FROM secrets WHERE id = $1 AND user_id = $2 FOR UPDATE") "SELECT name, type, encrypted FROM secrets WHERE id = $1 AND user_id = $2 FOR UPDATE",
.bind(secret_id) )
.bind(user_id) .bind(secret_id)
.fetch_optional(&mut *tx) .bind(user_id)
.await .fetch_optional(&mut *tx)
.map_err(|e| map_entry_mutation_err(e.into(), lang))?; .await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let Some((old_name, old_type)) = secret_row else { let Some((old_name, old_type, old_encrypted)) = secret_row else {
let _ = tx.rollback().await; let _ = tx.rollback().await;
return Err(( return Err((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -988,13 +1016,47 @@ pub(super) async fn api_secret_patch(
let new_name = name.unwrap_or(&old_name).to_string(); let new_name = name.unwrap_or(&old_name).to_string();
let new_type = secret_type.unwrap_or(&old_type).to_string(); let new_type = secret_type.unwrap_or(&old_type).to_string();
let new_encrypted = if let Some(value) = secret_value {
let encrypted = secrets_core::crypto::encrypt_json(
master_key
.as_ref()
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "请先设置密码短语后再编辑密文值", "請先設定密碼短語後再編輯密文值", "Unlock your passphrase before editing secret values") })),
)
})?,
value,
)
.map_err(|e| map_entry_mutation_err(e, lang))?;
Some(encrypted)
} else {
None
};
let value_changed = new_encrypted.is_some();
if let Err(e) = secrets_core::db::snapshot_secret_history(
&mut tx,
secrets_core::db::SecretSnapshotParams {
secret_id,
name: &old_name,
encrypted: &old_encrypted,
action: if value_changed { "update" } else { "rename" },
},
)
.await
{
tracing::warn!(error = %e, %secret_id, "failed to snapshot secret history before patch");
}
let result = sqlx::query( let result = sqlx::query(
"UPDATE secrets SET name = $1, type = $2, version = version + 1, updated_at = NOW() \ "UPDATE secrets SET name = $1, type = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $3", WHERE id = $4",
) )
.bind(&new_name) .bind(&new_name)
.bind(&new_type) .bind(&new_type)
.bind(new_encrypted.as_deref().unwrap_or(&old_encrypted))
.bind(secret_id) .bind(secret_id)
.execute(&mut *tx) .execute(&mut *tx)
.await; .await;
@@ -1018,7 +1080,11 @@ pub(super) async fn api_secret_patch(
secrets_core::audit::log_tx( secrets_core::audit::log_tx(
&mut tx, &mut tx,
Some(user_id), Some(user_id),
"rename_secret", if value_changed {
"update_secret"
} else {
"rename_secret"
},
"", "",
"", "",
&old_name, &old_name,
@@ -1029,6 +1095,7 @@ pub(super) async fn api_secret_patch(
"new_name": new_name, "new_name": new_name,
"old_type": old_type, "old_type": old_type,
"new_type": new_type, "new_type": new_type,
"value_updated": value_changed,
"linked_entries": linked_entries, "linked_entries": linked_entries,
}), }),
) )
@@ -1154,23 +1221,7 @@ pub(super) async fn api_entry_secrets_decrypt(
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })), Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?; ))?;
let enc_key_hex = headers let master_key = require_encryption_key(&headers, lang)?;
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
let master_key =
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})?;
let secrets = let secrets =
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id)) get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))

View File

@@ -456,7 +456,16 @@
padding: 7px 10px; word-break: break-all; white-space: pre-wrap; padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
max-height: 140px; overflow: auto; color: #c9d1d9; line-height: 1.5; max-height: 140px; overflow: auto; color: #c9d1d9; line-height: 1.5;
} }
.view-secret-editor {
width: 100%; min-height: 108px; resize: vertical; box-sizing: border-box;
font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.5;
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
color: #c9d1d9; padding: 10px 12px; outline: none;
}
.view-secret-editor:focus { border-color: rgba(56,139,253,0.5); }
.view-secret-hint {
margin-top: 6px; font-size: 12px; color: #8b949e; line-height: 1.5;
}
.btn-icon { .btn-icon {
padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer; padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer;
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e; border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
@@ -484,7 +493,7 @@
.btn-view-edit { color: #58a6ff; } .btn-view-edit { color: #58a6ff; }
.btn-view-save { color: #3fb950; } .btn-view-save { color: #3fb950; }
.btn-view-cancel { color: #8b949e; } .btn-view-cancel { color: #8b949e; }
.btn-view-unlink { color: #f85149; font-size: 14px; } .btn-view-unlink { color: #f85149; font-size: 12px; white-space: nowrap; }
</style> </style>
</head> </head>
<body> <body>
@@ -763,6 +772,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewCopy: '复制', viewCopy: '复制',
viewCopied: '已复制', viewCopied: '已复制',
viewLoading: '解密中…', viewLoading: '解密中…',
viewEditSecret: '编辑密文',
viewValueHintJson: '此值按 JSON 保存,请输入合法 JSON。',
viewValueInvalidJson: '请输入合法 JSON 值',
viewValueUnlockRequired: '请先在 MCP 配置页解锁密码短语后再修改密文值。',
viewSaveChanges: '保存更改', viewSaveChanges: '保存更改',
viewChangesSaved: '已保存', viewChangesSaved: '已保存',
viewUnlinkConfirm: '确定解除密文关联「{name}」?', viewUnlinkConfirm: '确定解除密文关联「{name}」?',
@@ -843,6 +856,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewCopy: '複製', viewCopy: '複製',
viewCopied: '已複製', viewCopied: '已複製',
viewLoading: '解密中…', viewLoading: '解密中…',
viewEditSecret: '編輯密文',
viewValueHintJson: '此值會以 JSON 儲存,請輸入合法 JSON。',
viewValueInvalidJson: '請輸入合法 JSON 值',
viewValueUnlockRequired: '請先在 MCP 設定頁解鎖密碼短語,再修改密文值。',
viewSaveChanges: '儲存變更', viewSaveChanges: '儲存變更',
viewChangesSaved: '已儲存', viewChangesSaved: '已儲存',
viewUnlinkConfirm: '確定解除密文關聯「{name}」?', viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
@@ -923,6 +940,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewCopy: 'Copy', viewCopy: 'Copy',
viewCopied: 'Copied', viewCopied: 'Copied',
viewLoading: 'Decrypting…', viewLoading: 'Decrypting…',
viewEditSecret: 'Edit secret',
viewValueHintJson: 'This value is stored as JSON. Enter valid JSON.',
viewValueInvalidJson: 'Enter a valid JSON value',
viewValueUnlockRequired: 'Unlock your passphrase on the MCP config page before editing secret values.',
viewSaveChanges: 'Save changes', viewSaveChanges: 'Save changes',
viewChangesSaved: 'Saved', viewChangesSaved: 'Saved',
viewUnlinkConfirm: 'Unlink secret "{name}"?', viewUnlinkConfirm: 'Unlink secret "{name}"?',
@@ -1161,9 +1182,81 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (e.target === viewOverlay) closeView(); if (e.target === viewOverlay) closeView();
}); });
function parseEntrySecretSchema(tr) {
if (!tr) return [];
try {
var raw = JSON.parse(tr.getAttribute('data-entry-secrets') || '[]');
return Array.isArray(raw) ? raw : [];
} catch (err) {
return [];
}
}
function renderEntrySecretChips(tr, secretSchema) {
if (!tr) return;
var list = tr.querySelector('.col-secrets .secret-list');
if (!list) return;
list.innerHTML = '';
(secretSchema || []).forEach(function (secret) {
var chip = document.createElement('span');
chip.className = 'secret-chip';
chip.setAttribute('data-secret-id', secret.id || '');
var name = document.createElement('span');
name.className = 'secret-name';
name.title = secret.name || '';
name.textContent = secret.name || '';
var type = document.createElement('span');
type.className = 'secret-type';
type.textContent = secret.secret_type || 'text';
chip.appendChild(name);
chip.appendChild(type);
list.appendChild(chip);
});
var viewBtn = tr.querySelector('.btn-view-secrets');
if (viewBtn) viewBtn.disabled = !(secretSchema && secretSchema.length);
}
function writeEntrySecretSchema(entryId, secretSchema) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
tr.setAttribute('data-entry-secrets', JSON.stringify(secretSchema || []));
renderEntrySecretChips(tr, secretSchema || []);
}
function updateEntrySecretSchema(entryId, secretId, updater) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var changed = false;
var next = parseEntrySecretSchema(tr).map(function (secret) {
if (String(secret.id || '') !== String(secretId || '')) return secret;
changed = true;
return updater(Object.assign({}, secret));
});
if (changed) writeEntrySecretSchema(entryId, next);
}
function removeEntrySecretSchema(entryId, secretId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var schema = parseEntrySecretSchema(tr);
writeEntrySecretSchema(entryId, schema.filter(function (secret) {
return String(secret.id || '') !== String(secretId || '');
}));
}
function renderViewSecrets(secrets, secretSchema) { function renderViewSecrets(secrets, secretSchema) {
viewBody.innerHTML = ''; viewBody.innerHTML = '';
var names = Object.keys(secrets); var names = [];
(secretSchema || []).forEach(function (secret) {
if (Object.prototype.hasOwnProperty.call(secrets, secret.name)) names.push(secret.name);
});
Object.keys(secrets).forEach(function (name) {
if (names.indexOf(name) === -1) names.push(name);
});
if (names.length === 0) { if (names.length === 0) {
var msg = document.createElement('div'); var msg = document.createElement('div');
msg.className = 'view-locked-msg'; msg.className = 'view-locked-msg';
@@ -1177,18 +1270,42 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
names.forEach(function (name) { names.forEach(function (name) {
var raw = secrets[name]; var raw = secrets[name];
var valueStr = (raw === null || raw === undefined) ? '' : var currentName = name;
(typeof raw === 'object') ? JSON.stringify(raw, null, 2) : String(raw); var valueMode = (typeof raw === 'string') ? 'text' : 'json';
var valueStr = (typeof raw === 'string') ? raw : JSON.stringify(raw, null, 2);
var schema = schemaMap[name] || {}; var schema = schemaMap[name] || {};
var secretId = schema.id || ''; var secretId = schema.id || '';
var secretType = schema.secret_type || 'text'; var currentType = schema.secret_type || 'text';
var originalName = name;
var hasChanges = false; function formatSecretValue(value) {
return (typeof value === 'string') ? value : JSON.stringify(value, null, 2);
}
function parseEditedSecretValue(text) {
if (valueMode === 'text') return { ok: true, value: text };
try {
return { ok: true, value: JSON.parse(text) };
} catch (err) {
return { ok: false, error: t('viewValueInvalidJson') };
}
}
function comparableSecretValue(value) {
return JSON.stringify(value);
}
function applyCurrentSecretValue(value) {
raw = value;
valueStr = formatSecretValue(value);
valueEl.textContent = valueStr;
valueEditor.value = valueStr;
}
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'view-secret-row'; row.className = 'view-secret-row';
row.setAttribute('data-secret-id', secretId); row.setAttribute('data-secret-id', secretId);
row.setAttribute('data-original-name', originalName); row.setAttribute('data-original-name', currentName);
var header = document.createElement('div'); var header = document.createElement('div');
header.className = 'view-secret-header'; header.className = 'view-secret-header';
@@ -1203,14 +1320,13 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var nameInput = document.createElement('input'); var nameInput = document.createElement('input');
nameInput.type = 'text'; nameInput.type = 'text';
nameInput.className = 'view-secret-name-input'; nameInput.className = 'view-secret-name-input';
nameInput.value = name; nameInput.value = currentName;
nameInput.placeholder = t('renameSecretPlaceholder'); nameInput.placeholder = t('renameSecretPlaceholder');
nameInput.setAttribute('data-original-name', originalName);
nameInput.hidden = true; nameInput.hidden = true;
var typeBadge = document.createElement('span'); var typeBadge = document.createElement('span');
typeBadge.className = 'view-secret-type'; typeBadge.className = 'view-secret-type';
typeBadge.textContent = secretType; typeBadge.textContent = currentType;
var typeSelect = document.createElement('select'); var typeSelect = document.createElement('select');
typeSelect.className = 'view-secret-type-select'; typeSelect.className = 'view-secret-type-select';
@@ -1219,13 +1335,13 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var option = document.createElement('option'); var option = document.createElement('option');
option.value = opt; option.value = opt;
option.textContent = opt; option.textContent = opt;
if (opt === secretType) option.selected = true; if (opt === currentType) option.selected = true;
typeSelect.appendChild(option); typeSelect.appendChild(option);
}); });
if (SECRET_TYPE_OPTIONS.indexOf(secretType) === -1 && secretType) { if (SECRET_TYPE_OPTIONS.indexOf(currentType) === -1 && currentType) {
var fallback = document.createElement('option'); var fallback = document.createElement('option');
fallback.value = secretType; fallback.value = currentType;
fallback.textContent = secretType; fallback.textContent = currentType;
fallback.selected = true; fallback.selected = true;
typeSelect.appendChild(fallback); typeSelect.appendChild(fallback);
} }
@@ -1242,8 +1358,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var editBtn = document.createElement('button'); var editBtn = document.createElement('button');
editBtn.type = 'button'; editBtn.type = 'button';
editBtn.className = 'btn-icon btn-view-edit'; editBtn.className = 'btn-icon btn-view-edit';
editBtn.textContent = '✎'; editBtn.textContent = t('viewEditSecret');
editBtn.title = t('renameSecretTitle');
var saveBtn = document.createElement('button'); var saveBtn = document.createElement('button');
saveBtn.type = 'button'; saveBtn.type = 'button';
@@ -1272,8 +1387,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var unlinkBtn = document.createElement('button'); var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button'; unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-icon btn-view-unlink'; unlinkBtn.className = 'btn-icon btn-view-unlink';
unlinkBtn.textContent = '×'; unlinkBtn.textContent = t('unlinkTitle');
unlinkBtn.title = t('unlinkTitle');
actions.appendChild(unlinkBtn); actions.appendChild(unlinkBtn);
actions.appendChild(editBtn); actions.appendChild(editBtn);
@@ -1287,9 +1401,23 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var valueEl = document.createElement('div'); var valueEl = document.createElement('div');
valueEl.className = 'view-secret-value'; valueEl.className = 'view-secret-value';
valueEl.textContent = valueStr; valueEl.textContent = valueStr;
var valueEditor = document.createElement('textarea');
valueEditor.className = 'view-secret-editor';
valueEditor.hidden = true;
valueEditor.value = valueStr;
valueWrap.appendChild(valueEl); valueWrap.appendChild(valueEl);
valueWrap.appendChild(valueEditor);
row.appendChild(valueWrap); row.appendChild(valueWrap);
var valueHint = null;
if (valueMode === 'json') {
valueHint = document.createElement('div');
valueHint.className = 'view-secret-hint';
valueHint.hidden = true;
valueHint.textContent = t('viewValueHintJson');
row.appendChild(valueHint);
}
var nameStatus = document.createElement('div'); var nameStatus = document.createElement('div');
nameStatus.className = 'secret-name-status'; nameStatus.className = 'secret-name-status';
nameStatus.setAttribute('data-status', 'idle'); nameStatus.setAttribute('data-status', 'idle');
@@ -1301,8 +1429,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
function enterEditMode() { function enterEditMode() {
nameSpan.hidden = true; nameSpan.hidden = true;
typeBadge.hidden = true; typeBadge.hidden = true;
valueEl.hidden = true;
nameInput.hidden = false; nameInput.hidden = false;
typeSelect.hidden = false; typeSelect.hidden = false;
valueEditor.hidden = false;
if (valueHint) valueHint.hidden = false;
saveBtn.hidden = false; saveBtn.hidden = false;
cancelBtn.hidden = false; cancelBtn.hidden = false;
editBtn.hidden = true; editBtn.hidden = true;
@@ -1313,8 +1444,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
function exitEditMode() { function exitEditMode() {
nameSpan.hidden = false; nameSpan.hidden = false;
typeBadge.hidden = false; typeBadge.hidden = false;
valueEl.hidden = false;
nameInput.hidden = true; nameInput.hidden = true;
typeSelect.hidden = true; typeSelect.hidden = true;
valueEditor.hidden = true;
if (valueHint) valueHint.hidden = true;
saveBtn.hidden = true; saveBtn.hidden = true;
cancelBtn.hidden = true; cancelBtn.hidden = true;
editBtn.hidden = false; editBtn.hidden = false;
@@ -1322,7 +1456,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
nameStatus.className = 'secret-name-status'; nameStatus.className = 'secret-name-status';
nameInput.value = nameSpan.textContent; nameInput.value = nameSpan.textContent;
typeSelect.value = typeBadge.textContent; typeSelect.value = typeBadge.textContent;
hasChanges = false; valueEditor.value = valueStr;
} }
editBtn.addEventListener('click', enterEditMode); editBtn.addEventListener('click', enterEditMode);
@@ -1337,7 +1471,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
nameStatus.className = 'secret-name-status'; nameStatus.className = 'secret-name-status';
debounceTimer = setTimeout(function () { debounceTimer = setTimeout(function () {
var newName = nameInput.value.trim(); var newName = nameInput.value.trim();
if (!newName || newName === originalName) return; if (!newName || newName === currentName) return;
nameStatus.textContent = t('checkingSecretName'); nameStatus.textContent = t('checkingSecretName');
nameStatus.className = 'secret-name-status checking'; nameStatus.className = 'secret-name-status checking';
var checkId = Date.now(); var checkId = Date.now();
@@ -1352,18 +1486,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (data.ok && data.available) { if (data.ok && data.available) {
nameStatus.textContent = t('secretNameAvailable'); nameStatus.textContent = t('secretNameAvailable');
nameStatus.className = 'secret-name-status success'; nameStatus.className = 'secret-name-status success';
hasChanges = true;
} else { } else {
nameStatus.textContent = data.error || t('secretNameTaken'); nameStatus.textContent = data.error || t('secretNameTaken');
nameStatus.className = 'secret-name-status error'; nameStatus.className = 'secret-name-status error';
hasChanges = false;
} }
}) })
.catch(function () { .catch(function () {
if (currentCheck !== checkId) return; if (currentCheck !== checkId) return;
nameStatus.textContent = t('secretNameCheckError'); nameStatus.textContent = t('secretNameCheckError');
nameStatus.className = 'secret-name-status error'; nameStatus.className = 'secret-name-status error';
hasChanges = false;
}); });
}, 300); }, 300);
}); });
@@ -1372,21 +1503,46 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (e.key === 'Enter') { e.preventDefault(); saveBtn.click(); } if (e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
if (e.key === 'Escape') { cancelBtn.click(); } if (e.key === 'Escape') { cancelBtn.click(); }
}); });
valueEditor.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
saveBtn.click();
}
if (e.key === 'Escape') cancelBtn.click();
});
// ── Save ── // ── Save ──
saveBtn.addEventListener('click', function () { saveBtn.addEventListener('click', function () {
var newName = nameInput.value.trim(); var newName = nameInput.value.trim();
var newType = typeSelect.value; var newType = typeSelect.value;
var parsedValue = parseEditedSecretValue(valueEditor.value);
if (!newName) { nameStatus.textContent = t('secretNameInvalid'); nameStatus.className = 'secret-name-status error'; return; } if (!newName) { nameStatus.textContent = t('secretNameInvalid'); nameStatus.className = 'secret-name-status error'; return; }
if (!newType) { nameStatus.textContent = t('secretTypeInvalid'); nameStatus.className = 'secret-name-status error'; return; } if (!newType) { nameStatus.textContent = t('secretTypeInvalid'); nameStatus.className = 'secret-name-status error'; return; }
if (!parsedValue.ok) { nameStatus.textContent = parsedValue.error; nameStatus.className = 'secret-name-status error'; return; }
var nextValue = parsedValue.value;
var patchBody = {}; var patchBody = {};
if (newName !== originalName) patchBody.name = newName; var valueChanged = comparableSecretValue(nextValue) !== comparableSecretValue(raw);
if (newType !== secretType) patchBody.type = newType; if (newName !== currentName) patchBody.name = newName;
if (newType !== currentType) patchBody.type = newType;
if (valueChanged) patchBody.value = nextValue;
if (Object.keys(patchBody).length === 0) { exitEditMode(); return; } if (Object.keys(patchBody).length === 0) { exitEditMode(); return; }
var encKey = sessionStorage.getItem('enc_key');
if (valueChanged && !encKey) {
nameStatus.textContent = t('viewValueUnlockRequired');
nameStatus.className = 'secret-name-status error';
return;
}
saveBtn.textContent = '...'; saveBtn.textContent = '...';
saveBtn.disabled = true;
cancelBtn.disabled = true;
editBtn.disabled = true;
fetch('/api/secrets/' + encodeURIComponent(secretId), { fetch('/api/secrets/' + encodeURIComponent(secretId), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: Object.assign(
{ 'Content-Type': 'application/json' },
valueChanged ? { 'X-Encryption-Key': encKey } : {}
),
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify(patchBody) body: JSON.stringify(patchBody)
}).then(function (r) { }).then(function (r) {
@@ -1395,22 +1551,28 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
return data; return data;
}); });
}).then(function () { }).then(function () {
currentName = newName;
currentType = newType;
nameSpan.textContent = newName; nameSpan.textContent = newName;
typeBadge.textContent = newType; typeBadge.textContent = newType;
originalName = newName; applyCurrentSecretValue(nextValue);
nameInput.setAttribute('data-original-name', newName);
saveBtn.textContent = t('viewChangesSaved'); saveBtn.textContent = t('viewChangesSaved');
nameStatus.textContent = t('viewChangesSaved');
nameStatus.className = 'secret-name-status success';
updateEntrySecretSchema(viewBody.getAttribute('data-entry-id'), secretId, function (secret) {
secret.name = newName;
secret.secret_type = newType;
return secret;
});
setTimeout(function () { exitEditMode(); saveBtn.textContent = t('viewSaveChanges'); }, 1200); setTimeout(function () { exitEditMode(); saveBtn.textContent = t('viewSaveChanges'); }, 1200);
// Update table row chip
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.secret-chip .secret-name');
if (chip && chip.textContent === name) chip.textContent = newName;
}
}).catch(function (err) { }).catch(function (err) {
nameStatus.textContent = err.message || String(err); nameStatus.textContent = err.message || String(err);
nameStatus.className = 'secret-name-status error'; nameStatus.className = 'secret-name-status error';
saveBtn.textContent = t('viewSaveChanges'); saveBtn.textContent = t('viewSaveChanges');
}).finally(function () {
saveBtn.disabled = false;
cancelBtn.disabled = false;
editBtn.disabled = false;
}); });
}); });
@@ -1434,15 +1596,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
msg.textContent = t('viewNoSecrets'); msg.textContent = t('viewNoSecrets');
viewBody.appendChild(msg); viewBody.appendChild(msg);
} }
// Update table row removeEntrySecretSchema(viewBody.getAttribute('data-entry-id'), secretId);
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.secret-chip');
if (chip) {
var chipName = chip.querySelector('.secret-name');
if (chipName && chipName.textContent === name) chip.remove();
}
}
}).catch(function (err) { }).catch(function (err) {
alert(err.message || String(err)); alert(err.message || String(err));
}); });

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml"> <link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — 回收站</title> <title data-i18n="pageTitle">Secrets — 回收站</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
@@ -188,6 +188,7 @@
(function () { (function () {
I18N_PAGE = { I18N_PAGE = {
'zh-CN': { 'zh-CN': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计', navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。', signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。',
emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹', emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹',
@@ -197,6 +198,7 @@
mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作' mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作'
}, },
'zh-TW': { 'zh-TW': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計', navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。', signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。',
emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾', emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾',
@@ -206,6 +208,7 @@
mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作' mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作'
}, },
en: { en: {
pageTitle: 'Secrets — Trash',
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit', navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
signOut: 'Sign out', trashTitle: 'Trash', trashSubtitle: 'Deleted entries are kept for 3 months. Restore or permanently delete them here.', signOut: 'Sign out', trashTitle: 'Trash', trashSubtitle: 'Deleted entries are kept for 3 months. Restore or permanently delete them here.',
emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder', emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder',