Compare commits
4 Commits
secrets-mc
...
secrets-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf93488c6a | ||
| 137a4d42b0 | |||
|
|
ff2ea91e72 | ||
|
|
574c1c9967 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2065,7 +2065,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.14"
|
version = "0.5.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.14"
|
version = "0.5.18"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -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 之一", "至少需要提供 name 或 type 之一", "At least one of name or type is required") }),
|
json!({ "error": tr(lang, "至少需要提供 name、type 或 value 之一", "至少需要提供 name、type 或 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(secret_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
.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))
|
||||||
|
|||||||
@@ -73,8 +73,7 @@
|
|||||||
border-color: rgba(56,139,253,0.3);
|
border-color: rgba(56,139,253,0.3);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.filter-bar {
|
.filter-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
||||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
|
||||||
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
@@ -114,6 +113,29 @@
|
|||||||
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
|
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
|
.btn-col-toggle {
|
||||||
|
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
|
background: transparent; color: #8b949e; font-size: 16px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-col-toggle:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
|
.col-menu { position: relative; }
|
||||||
|
.col-panel {
|
||||||
|
display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 20;
|
||||||
|
background: #161b22; border: 1px solid rgba(240,246,252,0.12); border-radius: 10px;
|
||||||
|
padding: 10px 14px; min-width: 180px; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.col-panel.open { display: block; }
|
||||||
|
.col-panel-group { font-size: 11px; color: #6e7681; text-transform: uppercase; letter-spacing: 0.5px; margin: 8px 0 4px; }
|
||||||
|
.col-panel-group:first-child { margin-top: 0; }
|
||||||
|
.col-panel-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 4px 0;
|
||||||
|
font-size: 13px; color: #c9d1d9; cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.col-panel-item input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.col-panel-item.disabled { color: #6e7681; cursor: default; }
|
||||||
|
.col-panel-item.disabled input[type="checkbox"] { cursor: default; }
|
||||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -123,10 +145,18 @@
|
|||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 960px;
|
min-width: 1100px;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
col[data-col="name"] { width: 220px; }
|
||||||
|
col[data-col="type"] { width: 120px; }
|
||||||
|
col[data-col="notes"] { width: 320px; }
|
||||||
|
col[data-col="tags"] { width: 220px; }
|
||||||
|
col[data-col="relations"] { width: 220px; }
|
||||||
|
col[data-col="secrets"] { width: 320px; }
|
||||||
|
col[data-col="actions"] { width: 132px; }
|
||||||
th, td { text-align: left; vertical-align: middle; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
th, td { text-align: left; vertical-align: middle; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
||||||
th {
|
th {
|
||||||
color: #8b949e;
|
color: #8b949e;
|
||||||
@@ -142,13 +172,21 @@
|
|||||||
}
|
}
|
||||||
td { font-size: 13px; line-height: 1.45; color: #c9d1d9; }
|
td { font-size: 13px; line-height: 1.45; color: #c9d1d9; }
|
||||||
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); }
|
||||||
|
tbody tr:nth-child(2n) td.col-name { background: #0f1620; }
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
|
.col-type { text-align: center; vertical-align: middle; }
|
||||||
.col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; }
|
.col-secrets { vertical-align: middle; }
|
||||||
.col-tags { min-width: 160px; max-width: 220px; }
|
|
||||||
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: middle; }
|
|
||||||
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
|
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
|
||||||
.col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; }
|
.col-actions { text-align: right; vertical-align: middle; }
|
||||||
|
.col-name { position: sticky; left: 0; z-index: 1; background: #0d1117; overflow: hidden; }
|
||||||
|
th.col-name { z-index: 3; background: #111827; }
|
||||||
|
.col-name::after {
|
||||||
|
content: ''; position: absolute; top: 0; right: -8px; bottom: 0; width: 8px;
|
||||||
|
background: linear-gradient(to right, rgba(0,0,0,0.15), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
th[data-col="actions"], td[data-col="actions"] { text-align: right; }
|
||||||
|
[data-col].col-hidden { display: none !important; }
|
||||||
.cell-name, .cell-tags-val {
|
.cell-name, .cell-tags-val {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -199,6 +237,17 @@
|
|||||||
border-left: 1px solid rgba(240,246,252,0.08);
|
border-left: 1px solid rgba(240,246,252,0.08);
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
|
a.secret-chip {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
a.secret-chip:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(88,166,255,0.12);
|
||||||
|
}
|
||||||
.btn-unlink-secret {
|
.btn-unlink-secret {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -357,6 +406,9 @@
|
|||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
}
|
}
|
||||||
.col-name, .col-type, .col-actions { text-align: left; }
|
.col-name, .col-type, .col-actions { text-align: left; }
|
||||||
|
.col-name { position: static; }
|
||||||
|
.col-name::after { display: none; }
|
||||||
|
.col-panel { position: fixed; left: 12px; right: 12px; width: auto; }
|
||||||
th, td { vertical-align: top; }
|
th, td { vertical-align: top; }
|
||||||
.row-actions { justify-content: flex-start; }
|
.row-actions { justify-content: flex-start; }
|
||||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
.detail, .notes-scroll, .secret-list { max-width: none; }
|
||||||
@@ -404,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-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
|
.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;
|
||||||
@@ -432,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>
|
||||||
@@ -497,6 +558,10 @@
|
|||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button type="submit" class="btn-filter" data-i18n="filterSubmit">筛选</button>
|
<button type="submit" class="btn-filter" data-i18n="filterSubmit">筛选</button>
|
||||||
<a href="/entries" class="btn-clear" data-i18n="filterClear">清空</a>
|
<a href="/entries" class="btn-clear" data-i18n="filterClear">清空</a>
|
||||||
|
<div class="col-menu">
|
||||||
|
<button type="button" class="btn-col-toggle" id="col-toggle-btn" data-i18n-title="columnSettings" title="显示列">▥</button>
|
||||||
|
<div class="col-panel" id="col-panel"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -505,25 +570,34 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col data-col="name">
|
||||||
|
<col data-col="type">
|
||||||
|
<col data-col="notes">
|
||||||
|
<col data-col="tags">
|
||||||
|
<col data-col="relations">
|
||||||
|
<col data-col="secrets">
|
||||||
|
<col data-col="actions">
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-i18n="colName">名称</th>
|
<th data-col="name" data-i18n="colName">名称</th>
|
||||||
<th data-i18n="colType">类型</th>
|
<th data-col="type" data-i18n="colType">类型</th>
|
||||||
<th data-i18n="colNotes">备注</th>
|
<th data-col="notes" data-i18n="colNotes">备注</th>
|
||||||
<th data-i18n="colTags">标签</th>
|
<th data-col="tags" data-i18n="colTags">标签</th>
|
||||||
<th data-i18n="colRelations">关联</th>
|
<th data-col="relations" data-i18n="colRelations">关联</th>
|
||||||
<th data-i18n="colSecrets">密文</th>
|
<th data-col="secrets" data-i18n="colSecrets">密文</th>
|
||||||
<th data-i18n="colActions">操作</th>
|
<th data-col="actions" data-i18n="colActions">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-entry-parents="{{ entry.parents_json }}" data-updated-at="{{ entry.updated_at_iso }}">
|
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-entry-parents="{{ entry.parents_json }}" data-updated-at="{{ entry.updated_at_iso }}">
|
||||||
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
|
<td class="col-name mono cell-name" data-col="name" data-label="名称">{{ entry.name }}</td>
|
||||||
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
|
<td class="col-type mono cell-type" data-col="type" data-label="类型">{{ entry.entry_type }}</td>
|
||||||
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
|
<td class="col-notes cell-notes" data-col="notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
|
||||||
<td class="col-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
|
<td class="col-tags mono cell-tags-val" data-col="tags" data-label="标签">{{ entry.tags }}</td>
|
||||||
<td class="col-relations" data-label="关联">
|
<td class="col-relations" data-col="relations" data-label="关联">
|
||||||
<div class="secret-list">
|
<div class="secret-list">
|
||||||
{% for parent in entry.parents %}
|
{% for parent in entry.parents %}
|
||||||
<a class="secret-chip" href="{{ parent.href }}" title="{{ parent.folder }} / {{ parent.name }}">
|
<a class="secret-chip" href="{{ parent.href }}" title="{{ parent.folder }} / {{ parent.name }}">
|
||||||
@@ -539,7 +613,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-secrets" data-label="密文">
|
<td class="col-secrets" data-col="secrets" data-label="密文">
|
||||||
<div class="secret-list">
|
<div class="secret-list">
|
||||||
{% for s in entry.secrets %}
|
{% for s in entry.secrets %}
|
||||||
<span class="secret-chip">
|
<span class="secret-chip">
|
||||||
@@ -549,7 +623,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions" data-label="操作">
|
<td class="col-actions" data-col="actions" data-label="操作">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
|
<button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
|
||||||
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
|
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
|
||||||
@@ -627,6 +701,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
I18N_PAGE = {
|
I18N_PAGE = {
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
pageTitle: 'Secrets — 条目',
|
pageTitle: 'Secrets — 条目',
|
||||||
|
columnSettings: '显示列',
|
||||||
|
fixedColumns: '固定列',
|
||||||
|
optionalColumns: '可选列',
|
||||||
navTrash: '回收站',
|
navTrash: '回收站',
|
||||||
entriesTitle: '我的条目',
|
entriesTitle: '我的条目',
|
||||||
allTab: '全部',
|
allTab: '全部',
|
||||||
@@ -694,9 +771,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
|
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
|
||||||
viewCopy: '复制',
|
viewCopy: '复制',
|
||||||
viewCopied: '已复制',
|
viewCopied: '已复制',
|
||||||
viewShow: '显示',
|
|
||||||
viewHide: '隐藏',
|
|
||||||
viewLoading: '解密中…',
|
viewLoading: '解密中…',
|
||||||
|
viewEditSecret: '编辑密文',
|
||||||
|
viewValueHintJson: '此值按 JSON 保存,请输入合法 JSON。',
|
||||||
|
viewValueInvalidJson: '请输入合法 JSON 值',
|
||||||
|
viewValueUnlockRequired: '请先在 MCP 配置页解锁密码短语后再修改密文值。',
|
||||||
viewSaveChanges: '保存更改',
|
viewSaveChanges: '保存更改',
|
||||||
viewChangesSaved: '已保存',
|
viewChangesSaved: '已保存',
|
||||||
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
||||||
@@ -706,6 +785,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
pageTitle: 'Secrets — 條目',
|
pageTitle: 'Secrets — 條目',
|
||||||
|
columnSettings: '顯示列',
|
||||||
|
fixedColumns: '固定列',
|
||||||
|
optionalColumns: '可選列',
|
||||||
navTrash: '回收站',
|
navTrash: '回收站',
|
||||||
entriesTitle: '我的條目',
|
entriesTitle: '我的條目',
|
||||||
allTab: '全部',
|
allTab: '全部',
|
||||||
@@ -773,9 +855,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
|
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
|
||||||
viewCopy: '複製',
|
viewCopy: '複製',
|
||||||
viewCopied: '已複製',
|
viewCopied: '已複製',
|
||||||
viewShow: '顯示',
|
|
||||||
viewHide: '隱藏',
|
|
||||||
viewLoading: '解密中…',
|
viewLoading: '解密中…',
|
||||||
|
viewEditSecret: '編輯密文',
|
||||||
|
viewValueHintJson: '此值會以 JSON 儲存,請輸入合法 JSON。',
|
||||||
|
viewValueInvalidJson: '請輸入合法 JSON 值',
|
||||||
|
viewValueUnlockRequired: '請先在 MCP 設定頁解鎖密碼短語,再修改密文值。',
|
||||||
viewSaveChanges: '儲存變更',
|
viewSaveChanges: '儲存變更',
|
||||||
viewChangesSaved: '已儲存',
|
viewChangesSaved: '已儲存',
|
||||||
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
||||||
@@ -785,6 +869,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
pageTitle: 'Secrets — Entries',
|
pageTitle: 'Secrets — Entries',
|
||||||
|
columnSettings: 'Columns',
|
||||||
|
fixedColumns: 'Fixed',
|
||||||
|
optionalColumns: 'Optional',
|
||||||
navTrash: 'Trash',
|
navTrash: 'Trash',
|
||||||
entriesTitle: 'My entries',
|
entriesTitle: 'My entries',
|
||||||
allTab: 'All',
|
allTab: 'All',
|
||||||
@@ -852,9 +939,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
|
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
|
||||||
viewCopy: 'Copy',
|
viewCopy: 'Copy',
|
||||||
viewCopied: 'Copied',
|
viewCopied: 'Copied',
|
||||||
viewShow: 'Show',
|
|
||||||
viewHide: 'Hide',
|
|
||||||
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}"?',
|
||||||
@@ -885,8 +974,96 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
if (td) td.setAttribute('data-label', t(map[sel]));
|
if (td) td.setAttribute('data-label', t(map[sel]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
rebuildColPanel();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var COL_ORDER = ['name', 'type', 'notes', 'tags', 'relations', 'secrets', 'actions'];
|
||||||
|
var COL_ALWAYS_ON = { name: true, actions: true };
|
||||||
|
var COL_DEFAULTS = { name: true, type: true, notes: false, tags: true, relations: true, secrets: false, actions: true };
|
||||||
|
var COL_STORAGE_KEY = 'entries_col_vis';
|
||||||
|
var colPanel = document.getElementById('col-panel');
|
||||||
|
var colToggleBtn = document.getElementById('col-toggle-btn');
|
||||||
|
|
||||||
|
function getColVis() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem(COL_STORAGE_KEY);
|
||||||
|
if (saved) { var parsed = JSON.parse(saved); if (parsed && typeof parsed === 'object') return parsed; }
|
||||||
|
} catch (e) {}
|
||||||
|
var defaults = {};
|
||||||
|
COL_ORDER.forEach(function (col) { defaults[col] = COL_DEFAULTS[col]; });
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveColVis(vis) {
|
||||||
|
try { localStorage.setItem(COL_STORAGE_KEY, JSON.stringify(vis)); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColVis(vis) {
|
||||||
|
COL_ORDER.forEach(function (col) {
|
||||||
|
var visible = vis[col] !== false;
|
||||||
|
document.querySelectorAll('[data-col="' + col + '"]').forEach(function (el) {
|
||||||
|
if (visible) {
|
||||||
|
el.classList.remove('col-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.add('col-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildColPanel() {
|
||||||
|
var vis = getColVis();
|
||||||
|
colPanel.innerHTML = '';
|
||||||
|
var fixedCols = ['name', 'actions'];
|
||||||
|
var optionalCols = COL_ORDER.filter(function (c) { return fixedCols.indexOf(c) === -1; });
|
||||||
|
|
||||||
|
function renderGroup(cols, groupKey) {
|
||||||
|
var groupLabel = document.createElement('div');
|
||||||
|
groupLabel.className = 'col-panel-group';
|
||||||
|
groupLabel.textContent = t(groupKey);
|
||||||
|
colPanel.appendChild(groupLabel);
|
||||||
|
cols.forEach(function (col) {
|
||||||
|
var item = document.createElement('label');
|
||||||
|
item.className = 'col-panel-item';
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
var i18nKey = 'col' + col.charAt(0).toUpperCase() + col.slice(1);
|
||||||
|
cb.checked = vis[col] !== false;
|
||||||
|
if (COL_ALWAYS_ON[col]) {
|
||||||
|
cb.disabled = true;
|
||||||
|
item.classList.add('disabled');
|
||||||
|
}
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
vis[col] = cb.checked;
|
||||||
|
saveColVis(vis);
|
||||||
|
applyColVis(vis);
|
||||||
|
});
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.textContent = t(i18nKey) || col;
|
||||||
|
item.appendChild(cb);
|
||||||
|
item.appendChild(span);
|
||||||
|
colPanel.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGroup(fixedCols, 'fixedColumns');
|
||||||
|
renderGroup(optionalCols, 'optionalColumns');
|
||||||
|
}
|
||||||
|
|
||||||
|
var colMenu = document.querySelector('.col-menu');
|
||||||
|
colToggleBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
colPanel.classList.toggle('open');
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!colMenu.contains(e.target)) {
|
||||||
|
colPanel.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
applyColVis(getColVis());
|
||||||
|
rebuildColPanel();
|
||||||
|
|
||||||
var editOverlay = document.getElementById('edit-overlay');
|
var editOverlay = document.getElementById('edit-overlay');
|
||||||
var editError = document.getElementById('edit-error');
|
var editError = document.getElementById('edit-error');
|
||||||
var editFolder = document.getElementById('edit-folder');
|
var editFolder = document.getElementById('edit-folder');
|
||||||
@@ -1005,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';
|
||||||
@@ -1021,21 +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 isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
|
var valueStr = (typeof raw === 'string') ? raw : JSON.stringify(raw, null, 2);
|
||||||
var masked = isPassword;
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -1050,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';
|
||||||
@@ -1066,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);
|
||||||
}
|
}
|
||||||
@@ -1089,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';
|
||||||
@@ -1104,19 +1372,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
cancelBtn.textContent = t('modalCancel');
|
cancelBtn.textContent = t('modalCancel');
|
||||||
cancelBtn.hidden = true;
|
cancelBtn.hidden = true;
|
||||||
|
|
||||||
if (isPassword) {
|
|
||||||
var toggleBtn = document.createElement('button');
|
|
||||||
toggleBtn.type = 'button';
|
|
||||||
toggleBtn.className = 'btn-icon btn-toggle-mask';
|
|
||||||
toggleBtn.textContent = t('viewShow');
|
|
||||||
toggleBtn.addEventListener('click', function () {
|
|
||||||
masked = !masked;
|
|
||||||
valueEl.classList.toggle('masked', masked);
|
|
||||||
toggleBtn.textContent = masked ? t('viewShow') : t('viewHide');
|
|
||||||
});
|
|
||||||
actions.appendChild(toggleBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
var copyBtn = document.createElement('button');
|
var copyBtn = document.createElement('button');
|
||||||
copyBtn.type = 'button';
|
copyBtn.type = 'button';
|
||||||
copyBtn.className = 'btn-icon';
|
copyBtn.className = 'btn-icon';
|
||||||
@@ -1132,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);
|
||||||
@@ -1145,11 +1399,25 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
var valueWrap = document.createElement('div');
|
var valueWrap = document.createElement('div');
|
||||||
valueWrap.className = 'view-secret-value-wrap';
|
valueWrap.className = 'view-secret-value-wrap';
|
||||||
var valueEl = document.createElement('div');
|
var valueEl = document.createElement('div');
|
||||||
valueEl.className = 'view-secret-value' + (masked ? ' masked' : '');
|
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');
|
||||||
@@ -1161,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;
|
||||||
@@ -1173,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;
|
||||||
@@ -1182,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);
|
||||||
@@ -1197,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();
|
||||||
@@ -1212,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);
|
||||||
});
|
});
|
||||||
@@ -1232,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) {
|
||||||
@@ -1255,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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1294,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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ function applyLang() {
|
|||||||
var key = el.getAttribute('data-i18n-ph');
|
var key = el.getAttribute('data-i18n-ph');
|
||||||
el.placeholder = t(key);
|
el.placeholder = t(key);
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
|
||||||
|
var key = el.getAttribute('data-i18n-title');
|
||||||
|
el.title = t(key);
|
||||||
|
});
|
||||||
document.querySelectorAll('.lang-btn').forEach(function (btn) {
|
document.querySelectorAll('.lang-btn').forEach(function (btn) {
|
||||||
var map = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' };
|
var map = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' };
|
||||||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user