feat: entry update links existing secrets (link_secret_names)
- 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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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");
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user