feat: entry update links existing secrets (link_secret_names)
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m19s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s

- secrets-core: update flow validates and applies secret links
- secrets-mcp: MCP tool params and UI for managing links on edit
- Align errors and templates; minor crypto/.gitignore tweaks

Made-with: Cursor
This commit is contained in:
voson
2026-04-04 20:30:32 +08:00
parent 4a1654c820
commit 0ffb81e57f
10 changed files with 321 additions and 19 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
*.pem *.pem
tmp/ tmp/
client_secret_*.apps.googleusercontent.com.json client_secret_*.apps.googleusercontent.com.json
node_modules/

2
Cargo.lock generated
View File

@@ -1969,7 +1969,7 @@ dependencies = [
[[package]] [[package]]
name = "secrets-mcp" name = "secrets-mcp"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"askama", "askama",

View File

@@ -5,6 +5,8 @@ use aes_gcm::{
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use serde_json::Value; use serde_json::Value;
use crate::error::AppError;
const NONCE_LEN: usize = 12; const NONCE_LEN: usize = 12;
// ─── AES-256-GCM encrypt / decrypt ─────────────────────────────────────────── // ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
@@ -38,7 +40,7 @@ pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let nonce = Nonce::from_slice(nonce_bytes); let nonce = Nonce::from_slice(nonce_bytes);
cipher cipher
.decrypt(nonce, ciphertext) .decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data")) .map_err(|_| AppError::DecryptionFailed.into())
} }
// ─── JSON helpers ───────────────────────────────────────────────────────────── // ─── JSON helpers ─────────────────────────────────────────────────────────────

View File

@@ -21,6 +21,9 @@ pub enum AppError {
#[error("Concurrent modification detected")] #[error("Concurrent modification detected")]
ConcurrentModification, ConcurrentModification,
#[error("Decryption failed — the encryption key may be incorrect")]
DecryptionFailed,
#[error(transparent)] #[error(transparent)]
Internal(#[from] anyhow::Error), Internal(#[from] anyhow::Error),
} }

View File

@@ -25,6 +25,8 @@ pub struct UpdateResult {
pub remove_meta: Vec<String>, pub remove_meta: Vec<String>,
pub secret_keys: Vec<String>, pub secret_keys: Vec<String>,
pub remove_secrets: Vec<String>, pub remove_secrets: Vec<String>,
pub linked_secrets: Vec<String>,
pub unlinked_secrets: Vec<String>,
} }
pub struct UpdateParams<'a> { pub struct UpdateParams<'a> {
@@ -39,6 +41,8 @@ pub struct UpdateParams<'a> {
pub secret_entries: &'a [String], pub secret_entries: &'a [String],
pub secret_types: &'a std::collections::HashMap<String, String>, pub secret_types: &'a std::collections::HashMap<String, String>,
pub remove_secrets: &'a [String], pub remove_secrets: &'a [String],
pub link_secret_names: &'a [String],
pub unlink_secret_names: &'a [String],
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
@@ -295,6 +299,101 @@ pub async fn run(
} }
} }
// Link existing secrets by name
let mut linked_secrets = Vec::new();
for link_name in params.link_secret_names {
let link_name = link_name.trim();
if link_name.is_empty() {
anyhow::bail!("link_secret_names contains an empty name");
}
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
.bind(uid)
.bind(link_name)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
.bind(link_name)
.fetch_all(&mut *tx)
.await?
};
match secret_ids.len() {
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
1 => {
sqlx::query(
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(row.id)
.bind(secret_ids[0])
.execute(&mut *tx)
.await?;
linked_secrets.push(link_name.to_string());
}
n => anyhow::bail!(
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
n,
link_name
),
}
}
// Unlink secrets by name
let mut unlinked_secrets = Vec::new();
for unlink_name in params.unlink_secret_names {
let unlink_name = unlink_name.trim();
if unlink_name.is_empty() {
continue;
}
#[derive(sqlx::FromRow)]
struct SecretToUnlink {
id: Uuid,
encrypted: Vec<u8>,
}
let secret: Option<SecretToUnlink> = sqlx::query_as(
"SELECT s.id, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1 AND s.name = $2",
)
.bind(row.id)
.bind(unlink_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(s) = secret {
if let Err(e) = db::snapshot_secret_history(
&mut tx,
db::SecretSnapshotParams {
secret_id: s.id,
name: unlink_name,
encrypted: &s.encrypted,
action: "delete",
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot secret field history before unlink");
}
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(row.id)
.bind(s.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM secrets s \
WHERE s.id = $1 \
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
)
.bind(s.id)
.execute(&mut *tx)
.await?;
unlinked_secrets.push(unlink_name.to_string());
}
}
let meta_keys = collect_key_paths(params.meta_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?;
let remove_meta_keys = collect_field_paths(params.remove_meta)?; let remove_meta_keys = collect_field_paths(params.remove_meta)?;
let secret_keys = collect_key_paths(params.secret_entries)?; let secret_keys = collect_key_paths(params.secret_entries)?;
@@ -314,6 +413,8 @@ pub async fn run(
"remove_meta": remove_meta_keys, "remove_meta": remove_meta_keys,
"secret_keys": secret_keys, "secret_keys": secret_keys,
"remove_secrets": remove_secret_keys, "remove_secrets": remove_secret_keys,
"linked_secrets": linked_secrets,
"unlinked_secrets": unlinked_secrets,
}), }),
) )
.await; .await;
@@ -330,6 +431,8 @@ pub async fn run(
remove_meta: remove_meta_keys, remove_meta: remove_meta_keys,
secret_keys, secret_keys,
remove_secrets: remove_secret_keys, remove_secrets: remove_secret_keys,
linked_secrets,
unlinked_secrets,
}) })
} }

View File

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

View File

@@ -28,6 +28,10 @@ pub fn app_error_to_mcp(err: &AppError) -> rmcp::ErrorData {
"The entry was modified by another request. Please refresh and try again.", "The entry was modified by another request. Please refresh and try again.",
None, None,
), ),
AppError::DecryptionFailed => rmcp::ErrorData::invalid_request(
"Decryption failed — the encryption key may be incorrect or does not match the data.",
None,
),
AppError::Internal(_) => rmcp::ErrorData::internal_error( AppError::Internal(_) => rmcp::ErrorData::internal_error(
"Request failed due to a server error. Check service logs if you need details.", "Request failed due to a server error. Check service logs if you need details.",
None, None,

View File

@@ -13,11 +13,65 @@ use rmcp::{
tool, tool_handler, tool_router, tool, tool_handler, tool_router,
}; };
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::{Deserialize, Deserializer, de};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
// ── Serde helpers for numeric parameters that may arrive as strings ──────────
mod deser {
use super::*;
/// Deserialize a value that may come as a JSON number or a JSON string.
pub fn option_u32_from_string<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Num(u32),
Str(String),
}
match Option::<NumOrStr>::deserialize(deserializer)? {
None => Ok(None),
Some(NumOrStr::Num(n)) => Ok(Some(n)),
Some(NumOrStr::Str(s)) => {
if s.is_empty() {
return Ok(None);
}
s.parse::<u32>().map(Some).map_err(de::Error::custom)
}
}
}
/// Deserialize an i64 that may come as a JSON number or a JSON string.
pub fn option_i64_from_string<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Num(i64),
Str(String),
}
match Option::<NumOrStr>::deserialize(deserializer)? {
None => Ok(None),
Some(NumOrStr::Num(n)) => Ok(Some(n)),
Some(NumOrStr::Str(s)) => {
if s.is_empty() {
return Ok(None);
}
s.parse::<i64>().map(Some).map_err(de::Error::custom)
}
}
}
}
use secrets_core::models::ExportFormat; use secrets_core::models::ExportFormat;
use secrets_core::service::{ use secrets_core::service::{
add::{AddParams, run as svc_add}, add::{AddParams, run as svc_add},
@@ -188,6 +242,7 @@ struct FindInput {
#[schemars(description = "Tag filters (all must match)")] #[schemars(description = "Tag filters (all must match)")]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
#[schemars(description = "Max results (default 20)")] #[schemars(description = "Max results (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>, limit: Option<u32>,
} }
@@ -215,8 +270,10 @@ struct SearchInput {
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")] #[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>, sort: Option<String>,
#[schemars(description = "Max results (default 20)")] #[schemars(description = "Max results (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>, limit: Option<u32>,
#[schemars(description = "Pagination offset (default 0)")] #[schemars(description = "Pagination offset (default 0)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
offset: Option<u32>, offset: Option<u32>,
} }
@@ -307,6 +364,14 @@ struct UpdateInput {
secret_types: Option<Map<String, Value>>, secret_types: Option<Map<String, Value>>,
#[schemars(description = "Secret field keys to remove")] #[schemars(description = "Secret field keys to remove")]
remove_secrets: Option<Vec<String>>, remove_secrets: Option<Vec<String>>,
#[schemars(
description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user."
)]
link_secret_names: Option<Vec<String>>,
#[schemars(
description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted."
)]
unlink_secret_names: Option<Vec<String>>,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
@@ -341,6 +406,7 @@ struct HistoryInput {
)] )]
id: Option<String>, id: Option<String>,
#[schemars(description = "Max history entries to return (default 20)")] #[schemars(description = "Max history entries to return (default 20)")]
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
limit: Option<u32>, limit: Option<u32>,
} }
@@ -357,6 +423,7 @@ struct RollbackInput {
)] )]
id: Option<String>, id: Option<String>,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")] #[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
to_version: Option<i64>, to_version: Option<i64>,
} }
@@ -643,7 +710,7 @@ impl SecretsService {
let value = let value =
get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id)) get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id))
.await .await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?; .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
tracing::info!( tracing::info!(
tool = "secrets_get", tool = "secrets_get",
@@ -657,7 +724,7 @@ impl SecretsService {
} else { } else {
let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id)) let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id))
.await .await
.map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?; .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?;
tracing::info!( tracing::info!(
tool = "secrets_get", tool = "secrets_get",
@@ -793,6 +860,8 @@ impl SecretsService {
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect(); .collect();
let remove_secrets = input.remove_secrets.unwrap_or_default(); let remove_secrets = input.remove_secrets.unwrap_or_default();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
let result = svc_update( let result = svc_update(
&self.pool, &self.pool,
@@ -807,6 +876,8 @@ impl SecretsService {
secret_entries: &secrets, secret_entries: &secrets,
secret_types: &secret_types_map, secret_types: &secret_types_map,
remove_secrets: &remove_secrets, remove_secrets: &remove_secrets,
link_secret_names: &link_secret_names,
unlink_secret_names: &unlink_secret_names,
user_id: Some(user_id), user_id: Some(user_id),
}, },
&user_key, &user_key,
@@ -1048,7 +1119,7 @@ impl SecretsService {
Some(&user_key), Some(&user_key),
) )
.await .await
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
let fmt = format.parse::<ExportFormat>().map_err(|e| { let fmt = format.parse::<ExportFormat>().map_err(|e| {
tracing::warn!( tracing::warn!(
@@ -1064,7 +1135,7 @@ impl SecretsService {
})?; })?;
let serialized = fmt let serialized = fmt
.serialize(&data) .serialize(&data)
.map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?;
tracing::info!( tracing::info!(
tool = "secrets_export", tool = "secrets_export",
@@ -1115,7 +1186,7 @@ impl SecretsService {
Some(user_id), Some(user_id),
) )
.await .await
.map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?; .map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?;
let entry_count = env_map.len(); let entry_count = env_map.len();
tracing::info!( tracing::info!(

View File

@@ -1149,6 +1149,12 @@ fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError {
json!({ "error": tr(lang, "条目已被修改,请刷新后重试", "條目已被修改,請重新整理後重試", "Entry was modified, please refresh and try again") }), json!({ "error": tr(lang, "条目已被修改,请刷新后重试", "條目已被修改,請重新整理後重試", "Entry was modified, please refresh and try again") }),
), ),
), ),
AppError::DecryptionFailed => (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "解密失败,请检查密码短语", "解密失敗,請檢查密碼短語", "Decryption failed — please check your passphrase") }),
),
),
AppError::Internal(_) => { AppError::Internal(_) => {
tracing::error!(error = %err, "internal error in entry mutation"); tracing::error!(error = %err, "internal error in entry mutation");
( (

View File

@@ -123,7 +123,7 @@
background: var(--bg); background: var(--bg);
} }
table { table {
width: max-content; width: 100%;
min-width: 960px; min-width: 960px;
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
@@ -142,12 +142,12 @@
td { font-size: 13px; line-height: 1.45; } td { font-size: 13px; line-height: 1.45; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); } tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; } .mono { font-family: 'JetBrains Mono', monospace; }
.col-type { min-width: 108px; } .col-type { min-width: 108px; width: 1%; }
.col-name { min-width: 180px; max-width: 260px; } .col-name { min-width: 180px; max-width: 260px; }
.col-tags { min-width: 160px; max-width: 220px; } .col-tags { min-width: 160px; max-width: 220px; }
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; } .col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; }
.col-secrets .secret-list { max-height: 120px; overflow: auto; } .col-secrets .secret-list { max-height: 120px; overflow: auto; }
.col-actions { min-width: 132px; } .col-actions { min-width: 132px; width: 1%; }
.cell-name, .cell-tags-val { .cell-name, .cell-tags-val {
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
@@ -961,6 +961,114 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
showDeleteErr(''); showDeleteErr('');
} }
function refreshListAfterSave(entryId, body, secretRows) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) { window.location.reload(); return; }
var nameCell = tr.querySelector('.cell-name');
if (nameCell) nameCell.textContent = body.name;
var typeCell = tr.querySelector('.cell-type');
if (typeCell) typeCell.textContent = body.type;
var notesCell = tr.querySelector('.cell-notes-val');
if (notesCell) {
if (body.notes) { notesCell.textContent = body.notes; }
else { var notesWrap = tr.querySelector('.cell-notes'); if (notesWrap) notesWrap.innerHTML = ''; }
}
var tagsCell = tr.querySelector('.cell-tags-val');
if (tagsCell) tagsCell.textContent = body.tags.join(', ');
var secretsList = tr.querySelector('.secret-list');
if (secretsList) {
secretsList.innerHTML = '';
secretRows.forEach(function (info) {
var chip = document.createElement('span');
chip.className = 'secret-chip';
var nameSpan = document.createElement('span');
nameSpan.className = 'secret-name';
nameSpan.textContent = info.newName;
nameSpan.title = info.newName;
var typeSpan = document.createElement('span');
typeSpan.className = 'secret-type';
typeSpan.textContent = info.newType || 'text';
var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-unlink-secret';
unlinkBtn.setAttribute('data-secret-id', info.secretId);
unlinkBtn.setAttribute('data-secret-name', info.newName);
unlinkBtn.title = t('unlinkTitle');
unlinkBtn.textContent = '\u00d7';
chip.appendChild(nameSpan);
chip.appendChild(typeSpan);
chip.appendChild(unlinkBtn);
secretsList.appendChild(chip);
});
}
tr.setAttribute('data-entry-folder', body.folder);
tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata));
var updatedSecrets = secretRows.map(function (info) {
return { id: info.secretId, name: info.newName, secret_type: info.newType || 'text' };
});
tr.setAttribute('data-entry-secrets', JSON.stringify(updatedSecrets));
}
function refreshListAfterDelete(entryId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
var folder = tr ? tr.getAttribute('data-entry-folder') : null;
if (tr) tr.remove();
var tbody = document.querySelector('table tbody');
if (tbody && !tbody.querySelector('tr[data-entry-id]')) {
var card = document.querySelector('.card');
if (card) {
var tableWrap = card.querySelector('.table-wrap');
if (tableWrap) tableWrap.remove();
var existingEmpty = card.querySelector('.empty');
if (!existingEmpty) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'empty';
emptyDiv.setAttribute('data-i18n', 'emptyEntries');
emptyDiv.textContent = t('emptyEntries');
var filterBar = card.querySelector('.filter-bar');
if (filterBar) { card.insertBefore(emptyDiv, filterBar.nextSibling); }
else { card.appendChild(emptyDiv); }
}
}
}
var allTab = document.querySelector('.folder-tab[data-all-tab="1"]');
if (allTab) {
var count = parseInt(allTab.getAttribute('data-count') || '0', 10);
if (count > 0) {
count -= 1;
allTab.setAttribute('data-count', String(count));
allTab.textContent = t('allTab') + ' (' + count + ')';
}
}
if (folder) {
document.querySelectorAll('.folder-tab:not([data-all-tab])').forEach(function (tab) {
if (tab.textContent.trim().indexOf(folder) === 0) {
var m = tab.textContent.match(/\((\d+)\)/);
if (m) {
var c = parseInt(m[1], 10);
if (c > 0) {
c -= 1;
tab.textContent = folder + ' (' + c + ')';
}
}
}
});
}
}
function refreshListAfterUnlink(entryId, secretId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var chip = tr.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
if (chip && chip.parentElement) chip.parentElement.remove();
var secrets = tr.getAttribute('data-entry-secrets');
try {
var arr = JSON.parse(secrets);
arr = arr.filter(function (s) { return s.id !== secretId; });
tr.setAttribute('data-entry-secrets', JSON.stringify(arr));
} catch (e) {}
}
document.getElementById('delete-cancel').addEventListener('click', closeDelete); document.getElementById('delete-cancel').addEventListener('click', closeDelete);
deleteOverlay.addEventListener('click', function (e) { deleteOverlay.addEventListener('click', function (e) {
if (e.target === deleteOverlay) closeDelete(); if (e.target === deleteOverlay) closeDelete();
@@ -975,8 +1083,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
}); });
}) })
.then(function () { .then(function () {
var deletedId = pendingDeleteId;
closeDelete(); closeDelete();
window.location.reload(); refreshListAfterDelete(deletedId);
}) })
.catch(function (e) { showDeleteErr(e.message || String(e)); }); .catch(function (e) { showDeleteErr(e.message || String(e)); });
}); });
@@ -1086,7 +1195,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
})); }));
}).then(function () { }).then(function () {
closeEdit(); closeEdit();
window.location.reload(); refreshListAfterSave(currentEntryId, body, secretRows);
}).catch(function (e) { }).catch(function (e) {
showEditErr(e.message || String(e)); showEditErr(e.message || String(e));
}); });
@@ -1102,7 +1211,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var secretId = btn.getAttribute('data-secret-id'); var secretId = btn.getAttribute('data-secret-id');
var secretName = btn.getAttribute('data-secret-name') || ''; var secretName = btn.getAttribute('data-secret-name') || '';
if (!entryId || !secretId) return; if (!entryId || !secretId) return;
if (!confirm(tf('confirmUnlinkSecret', { name: secretName }))) return;
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), { fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE', method: 'DELETE',
credentials: 'same-origin' credentials: 'same-origin'
@@ -1112,7 +1220,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
return data; return data;
}); });
}).then(function () { }).then(function () {
window.location.reload(); refreshListAfterUnlink(entryId, secretId);
}).catch(function (err) { }).catch(function (err) {
alert(err.message || String(err)); alert(err.message || String(err));
}); });
@@ -1126,7 +1234,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var secretId = btn.getAttribute('data-secret-id'); var secretId = btn.getAttribute('data-secret-id');
var secretName = btn.getAttribute('data-secret-name') || ''; var secretName = btn.getAttribute('data-secret-name') || '';
if (!entryId || !secretId) return; if (!entryId || !secretId) return;
if (!confirm(tf('confirmUnlinkSecret', { name: secretName }))) return;
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), { fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
method: 'DELETE', method: 'DELETE',
credentials: 'same-origin' credentials: 'same-origin'
@@ -1136,7 +1243,12 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
return data; return data;
}); });
}).then(function () { }).then(function () {
window.location.reload(); btn.closest('.secret-edit-row').remove();
var tableRow = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (tableRow) {
var chip = tableRow.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
if (chip && chip.parentElement) chip.parentElement.remove();
}
}).catch(function (err) { }).catch(function (err) {
alert(err.message || String(err)); alert(err.message || String(err));
}); });