style(dashboard): move version footer out of card
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.12"
|
||||
version = "0.5.13"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -26,6 +26,7 @@ use tracing_subscriber::fmt::time::FormatTime;
|
||||
|
||||
use secrets_core::config::resolve_db_config;
|
||||
use secrets_core::db::{create_pool, migrate};
|
||||
use secrets_core::service::delete::purge_expired_deleted_entries;
|
||||
|
||||
use crate::oauth::OAuthConfig;
|
||||
use crate::tools::SecretsService;
|
||||
@@ -169,6 +170,7 @@ async fn main() -> Result<()> {
|
||||
// Rate limiting
|
||||
let rate_limit_state = rate_limit::RateLimitState::new();
|
||||
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
|
||||
let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
|
||||
|
||||
let router = Router::new()
|
||||
.merge(web::web_router())
|
||||
@@ -212,9 +214,28 @@ async fn main() -> Result<()> {
|
||||
|
||||
session_cleanup.abort();
|
||||
rate_limit_cleanup.abort();
|
||||
recycle_bin_cleanup.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_recycle_bin_cleanup_task(pool: PgPool) {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match purge_expired_deleted_entries(&pool).await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!(purged_count = count, "purged expired recycle bin entries");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
|
||||
|
||||
@@ -168,8 +168,9 @@ use secrets_core::service::{
|
||||
export::{ExportParams, export as svc_export},
|
||||
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
|
||||
history::run as svc_history,
|
||||
relations::{add_parent_relation, get_relations_for_entries, remove_parent_relation},
|
||||
rollback::run as svc_rollback,
|
||||
search::{SearchParams, resolve_entry_by_id, run as svc_search},
|
||||
search::{SearchParams, resolve_entry, resolve_entry_by_id, run as svc_search},
|
||||
update::{UpdateParams, run as svc_update},
|
||||
};
|
||||
|
||||
@@ -373,6 +374,8 @@ struct FindInput {
|
||||
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
|
||||
)]
|
||||
query: Option<String>,
|
||||
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
|
||||
metadata_query: Option<String>,
|
||||
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
@@ -401,6 +404,8 @@ struct FindInput {
|
||||
struct SearchInput {
|
||||
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
|
||||
query: Option<String>,
|
||||
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
|
||||
metadata_query: Option<String>,
|
||||
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
@@ -486,6 +491,9 @@ struct AddInput {
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
link_secret_names: Option<Vec<String>>,
|
||||
#[schemars(description = "UUIDs of parent entries to link to this entry")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
parent_ids: Option<Vec<String>>,
|
||||
#[schemars(description = "Encryption key as a 64-char hex string. \
|
||||
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
||||
Use this when the MCP client cannot reliably forward custom headers.")]
|
||||
@@ -551,6 +559,12 @@ struct UpdateInput {
|
||||
)]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
unlink_secret_names: Option<Vec<String>>,
|
||||
#[schemars(description = "UUIDs of parent entries to link")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
add_parent_ids: Option<Vec<String>>,
|
||||
#[schemars(description = "UUIDs of parent entries to unlink")]
|
||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||
remove_parent_ids: Option<Vec<String>>,
|
||||
#[schemars(description = "Encryption key as a 64-char hex string. \
|
||||
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
||||
Use this when the MCP client cannot reliably forward custom headers.")]
|
||||
@@ -596,16 +610,8 @@ struct HistoryInput {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct RollbackInput {
|
||||
#[schemars(description = "Name of the entry")]
|
||||
name: String,
|
||||
#[schemars(
|
||||
description = "Folder for disambiguation when multiple entries share the same name (optional)"
|
||||
)]
|
||||
folder: Option<String>,
|
||||
#[schemars(
|
||||
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
|
||||
)]
|
||||
id: Option<String>,
|
||||
#[schemars(description = "Entry UUID (from secrets_find) for an existing, non-deleted entry")]
|
||||
id: String,
|
||||
#[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>,
|
||||
@@ -725,6 +731,10 @@ fn parse_uuid(s: &str) -> Result<Uuid, rmcp::ErrorData> {
|
||||
.map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None))
|
||||
}
|
||||
|
||||
fn parse_uuid_list(values: &[String]) -> Result<Vec<Uuid>, rmcp::ErrorData> {
|
||||
values.iter().map(|value| parse_uuid(value)).collect()
|
||||
}
|
||||
|
||||
// ── Tool implementations ──────────────────────────────────────────────────────
|
||||
|
||||
#[tool_router]
|
||||
@@ -752,6 +762,7 @@ impl SecretsService {
|
||||
name = input.name.as_deref(),
|
||||
name_query = input.name_query.as_deref(),
|
||||
query = input.query.as_deref(),
|
||||
metadata_query = input.metadata_query.as_deref(),
|
||||
"tool call start",
|
||||
);
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
@@ -764,6 +775,7 @@ impl SecretsService {
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
@@ -780,6 +792,7 @@ impl SecretsService {
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: "name",
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
@@ -792,11 +805,23 @@ impl SecretsService {
|
||||
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let relation_map = get_relations_for_entries(
|
||||
&self.pool,
|
||||
&result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.id)
|
||||
.collect::<Vec<_>>(),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
|
||||
|
||||
let entries: Vec<serde_json::Value> = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||
let schema: Vec<serde_json::Value> = result
|
||||
.secret_schemas
|
||||
.get(&e.id)
|
||||
@@ -819,6 +844,8 @@ impl SecretsService {
|
||||
"type": e.entry_type,
|
||||
"tags": e.tags,
|
||||
"metadata": e.metadata,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"secret_fields": schema,
|
||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
@@ -867,6 +894,7 @@ impl SecretsService {
|
||||
name = input.name.as_deref(),
|
||||
name_query = input.name_query.as_deref(),
|
||||
query = input.query.as_deref(),
|
||||
metadata_query = input.metadata_query.as_deref(),
|
||||
"tool call start",
|
||||
);
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
@@ -879,6 +907,7 @@ impl SecretsService {
|
||||
name_query: input.name_query.as_deref(),
|
||||
tags: &tags,
|
||||
query: input.query.as_deref(),
|
||||
metadata_query: input.metadata_query.as_deref(),
|
||||
sort: input.sort.as_deref().unwrap_or("name"),
|
||||
limit: input.limit.unwrap_or(20),
|
||||
offset: input.offset.unwrap_or(0),
|
||||
@@ -887,12 +916,24 @@ impl SecretsService {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
|
||||
let relation_map = get_relations_for_entries(
|
||||
&self.pool,
|
||||
&result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.id)
|
||||
.collect::<Vec<_>>(),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
|
||||
|
||||
let summary = input.summary.unwrap_or(false);
|
||||
let entries: Vec<serde_json::Value> = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||
if summary {
|
||||
serde_json::json!({
|
||||
"name": e.name,
|
||||
@@ -900,6 +941,8 @@ impl SecretsService {
|
||||
"type": e.entry_type,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
} else {
|
||||
@@ -926,6 +969,8 @@ impl SecretsService {
|
||||
"notes": e.notes,
|
||||
"tags": e.tags,
|
||||
"metadata": e.metadata,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"secret_fields": schema,
|
||||
"version": e.version,
|
||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
@@ -1066,6 +1111,7 @@ impl SecretsService {
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
|
||||
.collect();
|
||||
let link_secret_names = input.link_secret_names.unwrap_or_default();
|
||||
let parent_ids = parse_uuid_list(&input.parent_ids.unwrap_or_default())?;
|
||||
let folder = input.folder.as_deref().unwrap_or("");
|
||||
let entry_type = input.entry_type.as_deref().unwrap_or("");
|
||||
let notes = input.notes.as_deref().unwrap_or("");
|
||||
@@ -1089,6 +1135,15 @@ impl SecretsService {
|
||||
.await
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
||||
|
||||
let created_entry = resolve_entry(&self.pool, &input.name, Some(folder), Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
|
||||
for parent_id in parent_ids {
|
||||
add_parent_relation(&self.pool, parent_id, created_entry.id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_add",
|
||||
?user_id,
|
||||
@@ -1176,6 +1231,8 @@ impl SecretsService {
|
||||
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 add_parent_ids = parse_uuid_list(&input.add_parent_ids.unwrap_or_default())?;
|
||||
let remove_parent_ids = parse_uuid_list(&input.remove_parent_ids.unwrap_or_default())?;
|
||||
|
||||
let result = svc_update(
|
||||
&self.pool,
|
||||
@@ -1199,6 +1256,30 @@ impl SecretsService {
|
||||
.await
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
|
||||
|
||||
let entry_id = if let Some(id_str) = input.id.as_deref() {
|
||||
parse_uuid(id_str)?
|
||||
} else {
|
||||
resolve_entry(
|
||||
&self.pool,
|
||||
&resolved_name,
|
||||
resolved_folder.as_deref(),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?
|
||||
.id
|
||||
};
|
||||
for parent_id in add_parent_ids {
|
||||
add_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
|
||||
}
|
||||
for parent_id in remove_parent_ids {
|
||||
remove_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_update",
|
||||
?user_id,
|
||||
@@ -1354,32 +1435,15 @@ impl SecretsService {
|
||||
tracing::info!(
|
||||
tool = "secrets_rollback",
|
||||
?user_id,
|
||||
name = %input.name,
|
||||
id = ?input.id,
|
||||
id = %input.id,
|
||||
to_version = input.to_version,
|
||||
"tool call start",
|
||||
);
|
||||
let entry_id = parse_uuid(&input.id)?;
|
||||
|
||||
let (resolved_name, resolved_folder): (String, Option<String>) =
|
||||
if let Some(ref id_str) = input.id {
|
||||
let eid = parse_uuid(id_str)?;
|
||||
let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
|
||||
(entry.name, Some(entry.folder))
|
||||
} else {
|
||||
(input.name.clone(), input.folder.clone())
|
||||
};
|
||||
|
||||
let result = svc_rollback(
|
||||
&self.pool,
|
||||
&resolved_name,
|
||||
resolved_folder.as_deref(),
|
||||
input.to_version,
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
|
||||
let result = svc_rollback(&self.pool, entry_id, input.to_version, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
|
||||
|
||||
tracing::info!(
|
||||
tool = "secrets_rollback",
|
||||
|
||||
@@ -12,8 +12,12 @@ use uuid::Uuid;
|
||||
|
||||
use secrets_core::error::AppError;
|
||||
use secrets_core::service::{
|
||||
delete::delete_by_id,
|
||||
delete::{
|
||||
count_deleted_entries, delete_by_id, list_deleted_entries, purge_deleted_by_id,
|
||||
restore_deleted_by_id,
|
||||
},
|
||||
get_secret::get_all_secrets_by_id,
|
||||
relations::{RelationEntrySummary, get_relations_for_entries, set_parent_relations},
|
||||
search::{SearchParams, count_entries, fetch_secrets_for_entries, ilike_pattern, list_entries},
|
||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||
};
|
||||
@@ -40,6 +44,7 @@ struct EntriesPageTemplate {
|
||||
secret_type_options_json: String,
|
||||
filter_folder: String,
|
||||
filter_name: String,
|
||||
filter_metadata_query: String,
|
||||
filter_type: String,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
@@ -47,6 +52,18 @@ struct EntriesPageTemplate {
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "trash.html")]
|
||||
struct TrashPageTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
entries: Vec<TrashEntryView>,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
total_count: i64,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
/// Non-sensitive entry fields; `secrets` lists field names/types only (no ciphertext).
|
||||
struct EntryListItemView {
|
||||
id: String,
|
||||
@@ -61,6 +78,9 @@ struct EntryListItemView {
|
||||
secrets: Vec<SecretSummaryView>,
|
||||
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
|
||||
secrets_json: String,
|
||||
parents: Vec<RelationSummaryView>,
|
||||
children: Vec<RelationSummaryView>,
|
||||
parents_json: String,
|
||||
/// RFC3339 UTC; shown in edit dialog.
|
||||
updated_at_iso: String,
|
||||
}
|
||||
@@ -72,6 +92,15 @@ struct SecretSummaryView {
|
||||
secret_type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct RelationSummaryView {
|
||||
id: String,
|
||||
name: String,
|
||||
folder: String,
|
||||
entry_type: String,
|
||||
href: String,
|
||||
}
|
||||
|
||||
struct FolderTabView {
|
||||
name: String,
|
||||
count: i64,
|
||||
@@ -79,16 +108,32 @@ struct FolderTabView {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
struct TrashEntryView {
|
||||
id: String,
|
||||
name: String,
|
||||
folder: String,
|
||||
entry_type: String,
|
||||
deleted_at_iso: String,
|
||||
deleted_at_label: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EntriesQuery {
|
||||
folder: Option<String>,
|
||||
name: Option<String>,
|
||||
metadata_query: Option<String>,
|
||||
/// URL query key is `type` (maps to DB column `entries.type`).
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EntryOptionQuery {
|
||||
q: Option<String>,
|
||||
exclude_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ── Entry mutation error helpers ──────────────────────────────────────────────
|
||||
|
||||
type EntryApiError = (StatusCode, Json<serde_json::Value>);
|
||||
@@ -171,6 +216,23 @@ fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError {
|
||||
}
|
||||
}
|
||||
|
||||
fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| RelationSummaryView {
|
||||
id: item.id.to_string(),
|
||||
name: item.name.clone(),
|
||||
folder: item.folder.clone(),
|
||||
entry_type: item.entry_type.clone(),
|
||||
href: format!(
|
||||
"/entries?folder={}&name={}",
|
||||
urlencoding::encode(&item.folder),
|
||||
urlencoding::encode(&item.name)
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn entries_page(
|
||||
@@ -202,6 +264,12 @@ pub(super) async fn entries_page(
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let metadata_query_filter = q
|
||||
.metadata_query
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let count_params = SearchParams {
|
||||
folder: folder_filter.as_deref(),
|
||||
@@ -210,6 +278,7 @@ pub(super) async fn entries_page(
|
||||
name_query: name_filter.as_deref(),
|
||||
tags: &[],
|
||||
query: None,
|
||||
metadata_query: metadata_query_filter.as_deref(),
|
||||
sort: "updated",
|
||||
limit: ENTRIES_PAGE_LIMIT,
|
||||
offset: 0,
|
||||
@@ -223,7 +292,7 @@ pub(super) async fn entries_page(
|
||||
count: i64,
|
||||
}
|
||||
let mut folder_sql =
|
||||
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1".to_string();
|
||||
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1 AND deleted_at IS NULL".to_string();
|
||||
let mut bind_idx = 2;
|
||||
if type_filter.is_some() {
|
||||
folder_sql.push_str(&format!(" AND type = ${bind_idx}"));
|
||||
@@ -233,6 +302,13 @@ pub(super) async fn entries_page(
|
||||
folder_sql.push_str(&format!(" AND name ILIKE ${bind_idx} ESCAPE '\\'"));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if metadata_query_filter.is_some() {
|
||||
folder_sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
|
||||
WHERE (val #>> '{{}}') ILIKE ${bind_idx} ESCAPE '\\')"
|
||||
));
|
||||
bind_idx += 1;
|
||||
}
|
||||
let _ = bind_idx;
|
||||
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
|
||||
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
|
||||
@@ -242,6 +318,9 @@ pub(super) async fn entries_page(
|
||||
if let Some(n) = name_filter.as_deref() {
|
||||
folder_query = folder_query.bind(ilike_pattern(n));
|
||||
}
|
||||
if let Some(v) = metadata_query_filter.as_deref() {
|
||||
folder_query = folder_query.bind(ilike_pattern(v));
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TypeOptionRow {
|
||||
@@ -261,7 +340,7 @@ pub(super) async fn entries_page(
|
||||
},
|
||||
folder_query.fetch_all(&state.pool),
|
||||
sqlx::query_as::<_, TypeOptionRow>(
|
||||
"SELECT DISTINCT type FROM entries WHERE user_id = $1 ORDER BY type",
|
||||
"SELECT DISTINCT type FROM entries WHERE user_id = $1 AND deleted_at IS NULL ORDER BY type",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool),
|
||||
@@ -297,6 +376,12 @@ pub(super) async fn entries_page(
|
||||
tracing::error!(error = %e, "failed to load secret schema list for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load relation list for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if let Some(current) = type_filter.as_ref()
|
||||
&& !current.is_empty()
|
||||
&& !type_options.iter().any(|t| t == current)
|
||||
@@ -309,6 +394,7 @@ pub(super) async fn entries_page(
|
||||
folder: Option<&str>,
|
||||
entry_type: Option<&str>,
|
||||
name: Option<&str>,
|
||||
metadata_query: Option<&str>,
|
||||
page: Option<u32>,
|
||||
) -> String {
|
||||
let mut pairs: Vec<String> = Vec::new();
|
||||
@@ -327,6 +413,11 @@ pub(super) async fn entries_page(
|
||||
{
|
||||
pairs.push(format!("name={}", urlencoding::encode(n)));
|
||||
}
|
||||
if let Some(v) = metadata_query
|
||||
&& !v.is_empty()
|
||||
{
|
||||
pairs.push(format!("metadata_query={}", urlencoding::encode(v)));
|
||||
}
|
||||
if let Some(p) = page {
|
||||
pairs.push(format!("page={}", p));
|
||||
}
|
||||
@@ -346,6 +437,7 @@ pub(super) async fn entries_page(
|
||||
None,
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
metadata_query_filter.as_deref(),
|
||||
Some(1),
|
||||
),
|
||||
active: folder_filter.is_none(),
|
||||
@@ -357,6 +449,7 @@ pub(super) async fn entries_page(
|
||||
Some(&name),
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
metadata_query_filter.as_deref(),
|
||||
Some(1),
|
||||
),
|
||||
active: folder_filter.as_deref() == Some(name.as_str()),
|
||||
@@ -368,6 +461,7 @@ pub(super) async fn entries_page(
|
||||
let entries = rows
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||
let secrets: Vec<SecretSummaryView> = secret_schemas
|
||||
.get(&e.id)
|
||||
.map(|fields| {
|
||||
@@ -384,6 +478,9 @@ pub(super) async fn entries_page(
|
||||
let secrets_json = serde_json::to_string(&secrets).unwrap_or_else(|_| "[]".to_string());
|
||||
let metadata_json =
|
||||
serde_json::to_string(&e.metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
let parents = relation_views(&relations.parents);
|
||||
let children = relation_views(&relations.children);
|
||||
let parents_json = serde_json::to_string(&parents).unwrap_or_else(|_| "[]".to_string());
|
||||
EntryListItemView {
|
||||
id: e.id.to_string(),
|
||||
folder: e.folder,
|
||||
@@ -394,6 +491,9 @@ pub(super) async fn entries_page(
|
||||
metadata_json,
|
||||
secrets,
|
||||
secrets_json,
|
||||
parents,
|
||||
children,
|
||||
parents_json,
|
||||
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
}
|
||||
})
|
||||
@@ -414,6 +514,7 @@ pub(super) async fn entries_page(
|
||||
.unwrap_or_default(),
|
||||
filter_folder: folder_filter.unwrap_or_default(),
|
||||
filter_name: name_filter.unwrap_or_default(),
|
||||
filter_metadata_query: metadata_query_filter.unwrap_or_default(),
|
||||
filter_type: type_filter.unwrap_or_default(),
|
||||
current_page,
|
||||
total_pages,
|
||||
@@ -424,6 +525,56 @@ pub(super) async fn entries_page(
|
||||
render_template(tmpl)
|
||||
}
|
||||
|
||||
pub(super) async fn trash_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(q): Query<EntriesQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user = match require_valid_user(&state.pool, &session, "trash_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let total_count = count_deleted_entries(&state.pool, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, user_id = %user.id, "failed to count trash entries");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
|
||||
let rows = list_deleted_entries(&state.pool, user.id, ENTRIES_PAGE_LIMIT, offset)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, user_id = %user.id, "failed to load trash entries");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let entries = rows
|
||||
.into_iter()
|
||||
.map(|entry| TrashEntryView {
|
||||
id: entry.id.to_string(),
|
||||
name: entry.name,
|
||||
folder: entry.folder,
|
||||
entry_type: entry.entry_type,
|
||||
deleted_at_iso: entry.deleted_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
deleted_at_label: entry.deleted_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tmpl = TrashPageTemplate {
|
||||
user_name: user.name.clone(),
|
||||
user_email: user.email.clone().unwrap_or_default(),
|
||||
entries,
|
||||
current_page,
|
||||
total_pages,
|
||||
total_count,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
render_template(tmpl)
|
||||
}
|
||||
|
||||
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -435,6 +586,7 @@ pub(super) struct EntryPatchBody {
|
||||
notes: String,
|
||||
tags: Vec<String>,
|
||||
metadata: serde_json::Value,
|
||||
parent_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_patch(
|
||||
@@ -496,9 +648,70 @@ pub(super) async fn api_entry_patch(
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
if let Some(parent_ids) = body.parent_ids.as_deref() {
|
||||
set_parent_relations(&state.pool, entry_id, parent_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_options(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<EntryOptionQuery>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let query =
|
||||
q.q.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
if query.is_empty() {
|
||||
return Ok(Json(json!([])));
|
||||
}
|
||||
|
||||
let rows = list_entries(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: None,
|
||||
entry_type: None,
|
||||
name: None,
|
||||
name_query: Some(query),
|
||||
tags: &[],
|
||||
query: None,
|
||||
metadata_query: None,
|
||||
sort: "name",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
let options: Vec<_> = rows
|
||||
.into_iter()
|
||||
.filter(|entry| Some(entry.id) != q.exclude_id)
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(serde_json::Value::Array(options)))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_delete(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
@@ -517,6 +730,51 @@ pub(super) async fn api_entry_delete(
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"deleted": true,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_trash_restore(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
restore_deleted_by_id(&state.pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"restored": true,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_trash_purge(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
purge_deleted_by_id(&state.pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"purged": true,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/auth/logout", post(auth::auth_logout))
|
||||
.route("/dashboard", get(account::dashboard))
|
||||
.route("/entries", get(entries::entries_page))
|
||||
.route("/trash", get(entries::trash_page))
|
||||
.route("/audit", get(audit::audit_page))
|
||||
.route("/account/bind/google", get(auth::account_bind_google))
|
||||
.route("/account/unbind/{provider}", post(auth::account_unbind))
|
||||
@@ -200,6 +201,7 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/api/key-setup", post(account::api_key_setup))
|
||||
.route("/api/key-change", post(account::api_key_change))
|
||||
.route("/api/apikey", get(account::api_apikey_get))
|
||||
.route("/api/entries/options", get(entries::api_entry_options))
|
||||
.route(
|
||||
"/api/apikey/regenerate",
|
||||
post(account::api_apikey_regenerate),
|
||||
@@ -208,6 +210,11 @@ pub fn web_router() -> Router<AppState> {
|
||||
"/api/entries/{id}",
|
||||
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
|
||||
)
|
||||
.route("/api/trash/{id}/restore", post(entries::api_trash_restore))
|
||||
.route(
|
||||
"/api/trash/{id}",
|
||||
axum::routing::delete(entries::api_trash_purge),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||
axum::routing::delete(entries::api_entry_secret_unlink),
|
||||
|
||||
@@ -13,77 +13,87 @@
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
body { background: #0d1117; color: #c9d1d9; font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active {
|
||||
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
|
||||
background: rgba(56,139,253,0.14); color: #fff;
|
||||
}
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { background: var(--surface2); }
|
||||
.main { padding: 32px 24px 40px; flex: 1; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.main { padding: 16px 16px 24px; flex: 1; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title-row {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card-title { font-size: 20px; font-weight: 600; margin: 0; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin: 0; color: #fff; }
|
||||
.card-title-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
background: #0d1117;
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
||||
th { color: #8b949e; font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; color: #c9d1d9; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.detail {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
|
||||
max-width: 460px;
|
||||
.col-detail { min-width: 260px; max-width: 460px; }
|
||||
.detail-scroll {
|
||||
height: calc(1.5em * 3 + 20px);
|
||||
min-height: calc(1.5em * 3 + 20px);
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; }
|
||||
@@ -93,42 +103,43 @@
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
thead { display: none; }
|
||||
tr { border-top: 1px solid var(--border); padding: 12px 0; }
|
||||
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
|
||||
td { border-top: none; padding: 6px 0; }
|
||||
td::before {
|
||||
display: block; color: var(--text-muted); font-size: 11px;
|
||||
display: block; color: #8b949e; font-size: 11px;
|
||||
margin-bottom: 4px; text-transform: uppercase;
|
||||
content: attr(data-label);
|
||||
}
|
||||
.detail { max-width: none; }
|
||||
}
|
||||
.pagination {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 20px;
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 18px;
|
||||
justify-content: center; padding: 12px 0;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text); text-decoration: none;
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { background: var(--surface2); }
|
||||
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.page-btn-disabled {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #6e7681; font-size: 13px;
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.page-info {
|
||||
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link active" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -172,7 +183,7 @@
|
||||
<td class="col-time mono" data-label="时间"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
|
||||
<td class="col-action mono" data-label="动作">{{ entry.action }}</td>
|
||||
<td class="col-target mono" data-label="目标">{{ entry.target }}</td>
|
||||
<td class="col-detail" data-label="详情"><pre class="detail">{{ entry.detail }}</pre></td>
|
||||
<td class="col-detail" data-label="详情">{% if !entry.detail.is_empty() %}<pre class="detail-scroll">{{ entry.detail }}</pre>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -198,7 +209,7 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
I18N_PAGE = {
|
||||
@@ -228,6 +239,7 @@
|
||||
el.title = raw + ' (UTC)';
|
||||
}
|
||||
});
|
||||
|
||||
applyLang();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -18,110 +18,108 @@
|
||||
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
|
||||
.sidebar-link.active {
|
||||
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
||||
.btn-sign-out { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
|
||||
.btn-sign-out:hover { background: var(--surface2); }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
|
||||
/* Main content column */
|
||||
.main { display: flex; flex-direction: column; align-items: center;
|
||||
padding: 24px 20px 8px; min-height: 0; }
|
||||
.main { padding: 16px 16px 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.app-footer {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
padding: 4px 20px 12px;
|
||||
font-size: 12px;
|
||||
color: #9da7b3;
|
||||
padding: 12px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin-top: auto;
|
||||
}
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 100%; max-width: 980px; }
|
||||
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
|
||||
/* Form */
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
||||
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 9px 12px; border-radius: 6px;
|
||||
.field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
|
||||
.field input { width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||
color: #c9d1d9; padding: 9px 12px; border-radius: 10px;
|
||||
font-size: 13px; outline: none; }
|
||||
.field input:focus { border-color: var(--accent); }
|
||||
.field input:focus { border-color: rgba(56,139,253,0.5); }
|
||||
.pw-field { position: relative; }
|
||||
.pw-field > input { padding-right: 42px; }
|
||||
.pw-toggle {
|
||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border: none; border-radius: 6px;
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
width: 32px; height: 32px; border: none; border-radius: 8px;
|
||||
background: transparent; color: #8b949e; cursor: pointer;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
|
||||
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.pw-toggle:hover { color: #c9d1d9; background: rgba(240,246,252,0.06); }
|
||||
.pw-toggle:focus-visible { outline: 2px solid rgba(56,139,253,0.5); outline-offset: 2px; }
|
||||
.pw-icon svg { display: block; }
|
||||
.pw-icon.hidden { display: none; }
|
||||
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
|
||||
.error-msg { color: #f85149; font-size: 12px; margin-top: 6px; display: none; }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
|
||||
justify-content: center; padding: 10px 20px; border-radius: 7px;
|
||||
border: none; background: var(--accent); color: #0d1117;
|
||||
justify-content: center; padding: 10px 20px; border-radius: 10px;
|
||||
border: none; background: #388bfd; color: #fff;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:hover { background: #58a6ff; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
|
||||
border-radius: 5px; border: 1px solid var(--border); background: none;
|
||||
color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
||||
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px;
|
||||
border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: #161b22;
|
||||
color: #8b949e; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-sm:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
|
||||
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
|
||||
background: rgba(63,185,80,0.1); color: var(--success);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||||
padding: 11px 20px; border-radius: 10px; border: 1px solid #3fb950;
|
||||
background: rgba(63,185,80,0.1); color: #3fb950;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; font-family: inherit; }
|
||||
.btn-copy:hover { background: rgba(63,185,80,0.2); }
|
||||
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
|
||||
.btn-copy.copied { background: #3fb950; color: #0d1117; border-color: #3fb950; }
|
||||
|
||||
/* Config format switcher */
|
||||
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--surface2); color: var(--text-muted); cursor: pointer;
|
||||
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.08);
|
||||
background: #161b22; color: #8b949e; cursor: pointer;
|
||||
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
|
||||
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
|
||||
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
|
||||
.config-tab:hover { color: #c9d1d9; border-color: rgba(56,139,253,0.45); transform: translateY(-1px); }
|
||||
.config-tab.active { background: rgba(56,139,253,0.14); color: #fff; border-color: rgba(56,139,253,0.3); }
|
||||
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
|
||||
/* Config box */
|
||||
.config-wrap { position: relative; margin-bottom: 14px; }
|
||||
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
.config-box { background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
|
||||
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
|
||||
line-height: 1.7; color: #c9d1d9; overflow-x: auto; white-space: pre; }
|
||||
.config-box.locked { color: #8b949e; filter: blur(3px); user-select: none;
|
||||
pointer-events: none; }
|
||||
.config-key { color: #79c0ff; }
|
||||
.config-str { color: #a5d6ff; }
|
||||
.config-val { color: var(--accent); }
|
||||
.config-val { color: #58a6ff; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.divider { border: none; border-top: 1px solid rgba(240,246,252,0.08); margin: 20px 0; }
|
||||
|
||||
/* Actions row */
|
||||
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
@@ -135,34 +133,29 @@
|
||||
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
||||
z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal-bd.open { display: flex; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
.modal { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 28px; width: 100%; max-width: 420px; }
|
||||
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||||
.modal h3 { font-size: 18px; font-weight: 700; margin-bottom: 16px; color: #fff; }
|
||||
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
|
||||
background: var(--accent); color: #0d1117; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; }
|
||||
.btn-modal-ok:hover { background: var(--accent-hover); }
|
||||
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.btn-modal-cancel:hover { background: var(--surface2); }
|
||||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 10px; border: none;
|
||||
background: #388bfd; color: #fff; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.btn-modal-ok:hover { background: #58a6ff; }
|
||||
.btn-modal-cancel { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.btn-modal-cancel:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; }
|
||||
.sidebar-link { flex: 1; text-align: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.config-tabs { grid-template-columns: 1fr; }
|
||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
|
||||
.main { padding: 20px 12px 28px; }
|
||||
.card { padding: 16px; }
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
.main { padding: 16px 12px 6px; }
|
||||
.app-footer { padding: 4px 12px 10px; }
|
||||
.card { padding: 18px; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -171,11 +164,12 @@
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link active">MCP</a>
|
||||
<a href="/entries" class="sidebar-link">条目</a>
|
||||
<a href="/audit" class="sidebar-link">审计</a>
|
||||
<a href="/dashboard" class="sidebar-link active" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -293,13 +287,11 @@
|
||||
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<footer class="app-footer">{{ version }}</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /main -->
|
||||
</div><!-- /content-shell -->
|
||||
</div><!-- /layout -->
|
||||
|
||||
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
|
||||
<div class="modal-bd" id="change-modal">
|
||||
@@ -351,6 +343,7 @@
|
||||
|
||||
const T = {
|
||||
'zh-CN': {
|
||||
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
|
||||
signOut: '退出',
|
||||
lockedTitle: '获取 MCP 配置',
|
||||
labelPassphrase: '加密密码',
|
||||
@@ -388,6 +381,7 @@ const T = {
|
||||
ariaHidePw: '隐藏密码',
|
||||
},
|
||||
'zh-TW': {
|
||||
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||
signOut: '登出',
|
||||
lockedTitle: '取得 MCP 設定',
|
||||
labelPassphrase: '加密密碼',
|
||||
@@ -425,6 +419,7 @@ const T = {
|
||||
ariaHidePw: '隱藏密碼',
|
||||
},
|
||||
'en': {
|
||||
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
||||
signOut: 'Sign out',
|
||||
lockedTitle: 'Get MCP Config',
|
||||
labelPassphrase: 'Encryption password',
|
||||
|
||||
@@ -13,45 +13,44 @@
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
body { background: #0d1117; color: #c9d1d9; font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-logo span { color: var(--accent); }
|
||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active {
|
||||
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
|
||||
background: rgba(56,139,253,0.14); color: #fff;
|
||||
}
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { background: var(--surface2); }
|
||||
.main { padding: 32px 24px 40px; flex: 1; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 100%; max-width: 1480px; margin: 0 auto; }
|
||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
||||
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.main { padding: 16px 16px 24px; flex: 1; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; color: #fff; }
|
||||
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
|
||||
.folder-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -60,40 +59,40 @@
|
||||
}
|
||||
.folder-tab {
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
color: #8b949e;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: var(--bg);
|
||||
background: #0d1117;
|
||||
}
|
||||
.folder-tab:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
.folder-tab:hover { color: #c9d1d9; border-color: rgba(240,246,252,0.2); }
|
||||
.folder-tab.active {
|
||||
background: rgba(88,166,255,0.12);
|
||||
border-color: rgba(88,166,255,0.35);
|
||||
color: var(--text);
|
||||
background: rgba(56,139,253,0.14);
|
||||
border-color: rgba(56,139,253,0.3);
|
||||
color: #fff;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
||||
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
|
||||
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||||
.filter-field label { font-size: 12px; color: #8b949e; font-weight: 500; }
|
||||
.filter-field input {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||
color: #c9d1d9; padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
outline: none; width: 100%;
|
||||
}
|
||||
.filter-field select {
|
||||
background-color: var(--surface);
|
||||
background-color: #161b22;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
background-size: 12px 12px;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||
color: #c9d1d9;
|
||||
padding: 8px 2.8rem 8px 10px;
|
||||
font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
outline: none; width: 100%;
|
||||
@@ -103,24 +102,24 @@
|
||||
-moz-appearance: none;
|
||||
}
|
||||
.filter-field input:focus,
|
||||
.filter-field select:focus { border-color: var(--accent); }
|
||||
.filter-field select:focus { border-color: rgba(56,139,253,0.5); }
|
||||
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.btn-filter {
|
||||
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
|
||||
padding: 8px 16px; border-radius: 10px; border: none; background: #388bfd; color: #fff;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-filter:hover { background: var(--accent-hover); }
|
||||
.btn-filter:hover { background: #58a6ff; }
|
||||
.btn-clear {
|
||||
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
padding: 8px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: transparent;
|
||||
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-clear:hover { background: var(--surface2); color: var(--text); }
|
||||
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
||||
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 12px;
|
||||
background: #0d1117;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
@@ -128,20 +127,20 @@
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
th, td { text-align: left; vertical-align: middle; padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||
th, td { text-align: left; vertical-align: middle; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
||||
th {
|
||||
color: var(--text-muted);
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--surface);
|
||||
background: #111827;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
td { font-size: 13px; line-height: 1.45; }
|
||||
td { font-size: 13px; line-height: 1.45; color: #c9d1d9; }
|
||||
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
|
||||
@@ -163,13 +162,13 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
|
||||
max-width: 360px; max-height: 120px; overflow: auto;
|
||||
}
|
||||
@@ -180,11 +179,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--surface2);
|
||||
background: #161b22;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
@@ -196,8 +195,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.secret-type {
|
||||
color: var(--text-muted);
|
||||
border-left: 1px solid var(--border);
|
||||
color: #8b949e;
|
||||
border-left: 1px solid rgba(240,246,252,0.08);
|
||||
padding-left: 6px;
|
||||
}
|
||||
.btn-unlink-secret {
|
||||
@@ -224,17 +223,17 @@
|
||||
.secret-name-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 8px;
|
||||
color: #c9d1d9;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.secret-name-input:focus {
|
||||
border-color: var(--accent);
|
||||
border-color: rgba(56,139,253,0.5);
|
||||
}
|
||||
.secret-name-input.invalid {
|
||||
border-color: #f85149;
|
||||
@@ -245,10 +244,10 @@
|
||||
.secret-type-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: #161b22;
|
||||
border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 8px;
|
||||
color: #c9d1d9;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
@@ -256,7 +255,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.secret-type-select:focus {
|
||||
border-color: var(--accent);
|
||||
border-color: rgba(56,139,253,0.5);
|
||||
}
|
||||
.secret-type-select.invalid {
|
||||
border-color: #f85149;
|
||||
@@ -274,13 +273,13 @@
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
color: #8b949e;
|
||||
}
|
||||
.secret-name-status:empty {
|
||||
display: none;
|
||||
}
|
||||
.secret-name-status.checking {
|
||||
color: var(--accent);
|
||||
color: #58a6ff;
|
||||
}
|
||||
.secret-name-status.error {
|
||||
color: #f85149;
|
||||
@@ -289,11 +288,11 @@
|
||||
color: #3fb950;
|
||||
}
|
||||
.btn-row {
|
||||
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
||||
padding: 8px 12px; border-radius: 10px; font-size: 13px; cursor: pointer;
|
||||
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
.btn-row:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
|
||||
@@ -301,19 +300,19 @@
|
||||
}
|
||||
.modal-overlay[hidden] { display: none !important; }
|
||||
.modal {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
|
||||
}
|
||||
.modal.modal-wide {
|
||||
max-width: 800px;
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
|
||||
.modal-title { font-size: 18px; font-weight: 700; margin-bottom: 14px; color: #fff; }
|
||||
.modal-field { margin-bottom: 12px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
|
||||
.modal-field input, .modal-field textarea {
|
||||
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
color: #c9d1d9; padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.modal-field textarea { resize: vertical; }
|
||||
@@ -323,22 +322,23 @@
|
||||
}
|
||||
.modal-field textarea.metadata-edit { min-height: 140px; }
|
||||
.modal-readonly-value {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text-muted); padding: 8px 10px; font-size: 13px;
|
||||
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
color: #8b949e; padding: 8px 10px; font-size: 13px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.modal-secrets .secret-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
|
||||
.modal-error.visible { display: block; }
|
||||
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||||
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
|
||||
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
|
||||
.btn-modal.primary:hover { background: var(--accent-hover); }
|
||||
.btn-modal { padding: 8px 16px; border-radius: 10px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid rgba(240,246,252,0.12); background: transparent; color: #c9d1d9; }
|
||||
.btn-modal:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-modal.primary { background: #388bfd; color: #fff; border-color: transparent; font-weight: 600; }
|
||||
.btn-modal.primary:hover { background: #58a6ff; }
|
||||
.btn-modal.danger { border-color: #f85149; color: #f85149; }
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||
@@ -349,10 +349,10 @@
|
||||
.table-wrap { max-height: none; border: none; background: transparent; }
|
||||
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
|
||||
thead { display: none; }
|
||||
tr { border-top: 1px solid var(--border); padding: 12px 0; }
|
||||
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
|
||||
td { border-top: none; padding: 6px 0; max-width: none; }
|
||||
td::before {
|
||||
display: block; color: var(--text-muted); font-size: 11px;
|
||||
display: block; color: #8b949e; font-size: 11px;
|
||||
margin-bottom: 4px; text-transform: uppercase;
|
||||
content: attr(data-label);
|
||||
}
|
||||
@@ -362,26 +362,26 @@
|
||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
||||
}
|
||||
.pagination {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 20px;
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 18px;
|
||||
justify-content: center; padding: 12px 0;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text); text-decoration: none;
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { background: var(--surface2); }
|
||||
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.page-btn-disabled {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #6e7681; font-size: 13px;
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.page-info {
|
||||
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.view-secret-row {
|
||||
display: flex; flex-direction: column; gap: 4px; padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
}
|
||||
.view-secret-row:last-child { border-bottom: none; }
|
||||
.view-secret-header {
|
||||
@@ -389,59 +389,60 @@
|
||||
}
|
||||
.view-secret-name {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
color: var(--text); font-weight: 600;
|
||||
color: #c9d1d9; font-weight: 600;
|
||||
}
|
||||
.view-secret-type {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||
color: var(--text-muted); background: var(--surface2);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
|
||||
color: #8b949e; background: #161b22;
|
||||
border: 1px solid rgba(240,246,252,0.08); border-radius: 6px; padding: 1px 6px;
|
||||
}
|
||||
.view-secret-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||
.view-secret-value-wrap { position: relative; }
|
||||
.view-secret-value {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
|
||||
max-height: 140px; overflow: auto; color: var(--text); 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); }
|
||||
.btn-icon {
|
||||
padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
||||
padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer;
|
||||
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-icon:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
.btn-icon:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.view-locked-msg {
|
||||
font-size: 13px; color: var(--text-muted); padding: 16px 0;
|
||||
font-size: 13px; color: #8b949e; padding: 16px 0;
|
||||
line-height: 1.6; text-align: center;
|
||||
}
|
||||
.view-locked-msg a { color: var(--accent); }
|
||||
.view-locked-msg a { color: #58a6ff; }
|
||||
.view-secret-name-wrap { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||
.view-secret-name-input {
|
||||
width: 180px; max-width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 4px; color: var(--text); padding: 2px 8px; font-size: 12px;
|
||||
width: 180px; max-width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||
border-radius: 8px; color: #c9d1d9; padding: 2px 8px; font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace; outline: none;
|
||||
}
|
||||
.view-secret-name-input:focus { border-color: var(--accent); }
|
||||
.view-secret-name-input:focus { border-color: rgba(56,139,253,0.5); }
|
||||
.view-secret-type-select {
|
||||
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text); padding: 2px 6px; font-size: 11px;
|
||||
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||
color: #c9d1d9; padding: 2px 6px; font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
|
||||
}
|
||||
.view-secret-type-select:focus { border-color: var(--accent); }
|
||||
.btn-view-edit { color: var(--accent); }
|
||||
.view-secret-type-select:focus { border-color: rgba(56,139,253,0.5); }
|
||||
.btn-view-edit { color: #58a6ff; }
|
||||
.btn-view-save { color: #3fb950; }
|
||||
.btn-view-cancel { color: var(--text-muted); }
|
||||
.btn-view-cancel { color: #8b949e; }
|
||||
.btn-view-unlink { color: #f85149; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link active" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -480,6 +481,10 @@
|
||||
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
|
||||
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-metadata-query" data-i18n="filterMetadataLabel">元数据值</label>
|
||||
<input id="filter-metadata-query" name="metadata_query" type="text" value="{{ filter_metadata_query }}" data-i18n-ph="filterMetadataPlaceholder" placeholder="搜索元数据值" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-type" data-i18n="filterTypeLabel">类型</label>
|
||||
<select id="filter-type" name="type" onchange="this.form.requestSubmit();">
|
||||
@@ -506,17 +511,34 @@
|
||||
<th data-i18n="colType">类型</th>
|
||||
<th data-i18n="colNotes">备注</th>
|
||||
<th data-i18n="colTags">标签</th>
|
||||
<th data-i18n="colRelations">关联</th>
|
||||
<th data-i18n="colSecrets">密文</th>
|
||||
<th data-i18n="colActions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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-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-type mono cell-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-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
|
||||
<td class="col-relations" data-label="关联">
|
||||
<div class="secret-list">
|
||||
{% for parent in entry.parents %}
|
||||
<a class="secret-chip" href="{{ parent.href }}" title="{{ parent.folder }} / {{ parent.name }}">
|
||||
<span class="secret-name">{{ parent.name }}</span>
|
||||
<span class="secret-type" data-i18n="relationParentBadge">上级</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for child in entry.children %}
|
||||
<a class="secret-chip" href="{{ child.href }}" title="{{ child.folder }} / {{ child.name }}">
|
||||
<span class="secret-name">{{ child.name }}</span>
|
||||
<span class="secret-type" data-i18n="relationChildBadge">下级</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-secrets" data-label="密文">
|
||||
<div class="secret-list">
|
||||
{% for s in entry.secrets %}
|
||||
@@ -543,13 +565,13 @@
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
@@ -572,6 +594,12 @@
|
||||
<div class="modal-field"><label for="edit-tags" data-i18n="modalTags">标签(逗号分隔)</label><input id="edit-tags" type="text" autocomplete="off"></div>
|
||||
<div class="modal-field"><label data-i18n="modalUpdated">更新</label><div id="edit-updated-at" class="modal-readonly-value" aria-live="polite"></div></div>
|
||||
<div class="modal-field"><label for="edit-metadata" data-i18n="modalMetadata">元数据(JSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
|
||||
<div class="modal-field">
|
||||
<label for="edit-parent-search" data-i18n="modalParents">上级条目</label>
|
||||
<div id="edit-parent-list" class="secret-list"></div>
|
||||
<input id="edit-parent-search" type="text" autocomplete="off" data-i18n-ph="parentSearchPlaceholder" placeholder="按名称搜索条目">
|
||||
<div id="edit-parent-results" class="secret-list" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-modal" id="edit-cancel" data-i18n="modalCancel">取消</button>
|
||||
<button type="button" class="btn-modal primary" id="edit-save" data-i18n="modalSave">保存</button>
|
||||
@@ -579,31 +607,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="delete-overlay" class="modal-overlay" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-title">
|
||||
<div class="modal-title" id="delete-title" data-i18n="deleteTitle">确认删除</div>
|
||||
<div id="delete-error" class="modal-error"></div>
|
||||
<div class="modal-field">
|
||||
<div id="delete-message" style="font-size:14px;line-height:1.6;color:var(--text)"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-modal" id="delete-cancel" data-i18n="modalCancel">取消</button>
|
||||
<button type="button" class="btn-modal danger" id="delete-confirm" data-i18n="deleteConfirm">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="view-overlay" class="modal-overlay" hidden>
|
||||
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="view-title">
|
||||
<div class="modal-title" id="view-title" data-i18n="viewTitle">查看条目密文</div>
|
||||
<div id="view-entry-name" style="font-size:13px;color:var(--text-muted);margin-bottom:14px;font-family:'JetBrains Mono',monospace;"></div>
|
||||
<div id="view-entry-name" style="font-size:13px;color:#8b949e;margin-bottom:14px;font-family:'JetBrains Mono',monospace;"></div>
|
||||
<div id="view-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
|
||||
<script>
|
||||
var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-options').textContent);
|
||||
@@ -611,10 +627,13 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
I18N_PAGE = {
|
||||
'zh-CN': {
|
||||
pageTitle: 'Secrets — 条目',
|
||||
navTrash: '回收站',
|
||||
entriesTitle: '我的条目',
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名称',
|
||||
filterNamePlaceholder: '输入关键字',
|
||||
filterMetadataLabel: '元数据值',
|
||||
filterMetadataPlaceholder: '搜索元数据值',
|
||||
filterTypeLabel: '类型',
|
||||
filterTypeAll: '全部',
|
||||
filterSubmit: '筛选',
|
||||
@@ -624,8 +643,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
colType: '类型',
|
||||
colNotes: '备注',
|
||||
colTags: '标签',
|
||||
colRelations: '关联',
|
||||
colSecrets: '密文',
|
||||
colActions: '操作',
|
||||
relationParentBadge: '上级',
|
||||
relationChildBadge: '下级',
|
||||
rowEdit: '编辑条目',
|
||||
rowDelete: '删除',
|
||||
modalTitle: '编辑条目信息',
|
||||
@@ -636,16 +658,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
modalTags: '标签(逗号分隔)',
|
||||
modalUpdated: '更新',
|
||||
modalMetadata: '元数据(JSON 对象)',
|
||||
modalParents: '上级条目',
|
||||
modalSecrets: '密文',
|
||||
modalCancel: '取消',
|
||||
modalSave: '保存',
|
||||
deleteTitle: '确认删除',
|
||||
deleteConfirm: '删除',
|
||||
deleteMessage: '确定删除条目「{name}」?此操作不可撤销。',
|
||||
mobileLabelName: '名称',
|
||||
mobileLabelType: '类型',
|
||||
mobileLabelNotes: '备注',
|
||||
mobileLabelTags: '标签',
|
||||
mobileLabelRelations: '关联',
|
||||
mobileLabelSecrets: '密文',
|
||||
mobileLabelActions: '操作',
|
||||
errInvalidJson: '元数据不是合法 JSON',
|
||||
@@ -679,13 +700,19 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewSaveChanges: '保存更改',
|
||||
viewChangesSaved: '已保存',
|
||||
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
||||
parentSearchPlaceholder: '按名称搜索条目',
|
||||
parentSearchEmpty: '没有匹配的条目',
|
||||
removeParent: '移除上级',
|
||||
},
|
||||
'zh-TW': {
|
||||
pageTitle: 'Secrets — 條目',
|
||||
navTrash: '回收站',
|
||||
entriesTitle: '我的條目',
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名稱',
|
||||
filterNamePlaceholder: '輸入關鍵字',
|
||||
filterMetadataLabel: '中繼資料值',
|
||||
filterMetadataPlaceholder: '搜尋中繼資料值',
|
||||
filterTypeLabel: '類型',
|
||||
filterTypeAll: '全部',
|
||||
filterSubmit: '篩選',
|
||||
@@ -695,8 +722,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
colType: '類型',
|
||||
colNotes: '備註',
|
||||
colTags: '標籤',
|
||||
colRelations: '關聯',
|
||||
colSecrets: '密文',
|
||||
colActions: '操作',
|
||||
relationParentBadge: '上級',
|
||||
relationChildBadge: '下級',
|
||||
rowEdit: '編輯條目',
|
||||
rowDelete: '刪除',
|
||||
modalTitle: '編輯條目資訊',
|
||||
@@ -707,16 +737,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
modalTags: '標籤(逗號分隔)',
|
||||
modalUpdated: '更新時間',
|
||||
modalMetadata: '中繼資料(JSON 物件)',
|
||||
modalParents: '上級條目',
|
||||
modalSecrets: '密文',
|
||||
modalCancel: '取消',
|
||||
modalSave: '儲存',
|
||||
deleteTitle: '確認刪除',
|
||||
deleteConfirm: '刪除',
|
||||
deleteMessage: '確定刪除條目「{name}」?此操作不可復原。',
|
||||
mobileLabelName: '名稱',
|
||||
mobileLabelType: '類型',
|
||||
mobileLabelNotes: '備註',
|
||||
mobileLabelTags: '標籤',
|
||||
mobileLabelRelations: '關聯',
|
||||
mobileLabelSecrets: '密文',
|
||||
mobileLabelActions: '操作',
|
||||
errInvalidJson: '中繼資料不是合法 JSON',
|
||||
@@ -750,13 +779,19 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewSaveChanges: '儲存變更',
|
||||
viewChangesSaved: '已儲存',
|
||||
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
||||
parentSearchPlaceholder: '依名稱搜尋條目',
|
||||
parentSearchEmpty: '沒有符合的條目',
|
||||
removeParent: '移除上級',
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Secrets — Entries',
|
||||
navTrash: 'Trash',
|
||||
entriesTitle: 'My entries',
|
||||
allTab: 'All',
|
||||
filterNameLabel: 'Name',
|
||||
filterNamePlaceholder: 'Enter keywords',
|
||||
filterMetadataLabel: 'Metadata value',
|
||||
filterMetadataPlaceholder: 'Search metadata values',
|
||||
filterTypeLabel: 'Type',
|
||||
filterTypeAll: 'All',
|
||||
filterSubmit: 'Filter',
|
||||
@@ -766,8 +801,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
colType: 'Type',
|
||||
colNotes: 'Notes',
|
||||
colTags: 'Tags',
|
||||
colRelations: 'Relations',
|
||||
colSecrets: 'Secrets',
|
||||
colActions: 'Actions',
|
||||
relationParentBadge: 'Parent',
|
||||
relationChildBadge: 'Child',
|
||||
rowEdit: 'Edit entry',
|
||||
rowDelete: 'Delete',
|
||||
modalTitle: 'Edit entry details',
|
||||
@@ -778,16 +816,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
modalTags: 'Tags (comma separated)',
|
||||
modalUpdated: 'Updated',
|
||||
modalMetadata: 'Metadata (JSON object)',
|
||||
modalParents: 'Parent entries',
|
||||
modalSecrets: 'Secrets',
|
||||
modalCancel: 'Cancel',
|
||||
modalSave: 'Save',
|
||||
deleteTitle: 'Confirm Delete',
|
||||
deleteConfirm: 'Delete',
|
||||
deleteMessage: 'Are you sure you want to delete entry "{name}"? This action cannot be undone.',
|
||||
mobileLabelName: 'Name',
|
||||
mobileLabelType: 'Type',
|
||||
mobileLabelNotes: 'Notes',
|
||||
mobileLabelTags: 'Tags',
|
||||
mobileLabelRelations: 'Relations',
|
||||
mobileLabelSecrets: 'Secrets',
|
||||
mobileLabelActions: 'Actions',
|
||||
errInvalidJson: 'Metadata is not valid JSON',
|
||||
@@ -821,6 +858,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewSaveChanges: 'Save changes',
|
||||
viewChangesSaved: 'Saved',
|
||||
viewUnlinkConfirm: 'Unlink secret "{name}"?',
|
||||
parentSearchPlaceholder: 'Search entries by name',
|
||||
parentSearchEmpty: 'No matching entries',
|
||||
removeParent: 'Remove parent',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -836,6 +876,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
'.col-type': 'mobileLabelType',
|
||||
'.col-notes': 'mobileLabelNotes',
|
||||
'.col-tags': 'mobileLabelTags',
|
||||
'.col-relations': 'mobileLabelRelations',
|
||||
'.col-secrets': 'mobileLabelSecrets',
|
||||
'.col-actions': 'mobileLabelActions'
|
||||
};
|
||||
@@ -855,11 +896,98 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
var editTags = document.getElementById('edit-tags');
|
||||
var editMetadata = document.getElementById('edit-metadata');
|
||||
var editUpdatedAt = document.getElementById('edit-updated-at');
|
||||
var deleteOverlay = document.getElementById('delete-overlay');
|
||||
var deleteError = document.getElementById('delete-error');
|
||||
var deleteMessage = document.getElementById('delete-message');
|
||||
var editParentList = document.getElementById('edit-parent-list');
|
||||
var editParentSearch = document.getElementById('edit-parent-search');
|
||||
var editParentResults = document.getElementById('edit-parent-results');
|
||||
var currentEntryId = null;
|
||||
var pendingDeleteId = null;
|
||||
var selectedParents = [];
|
||||
var parentSearchTimer = null;
|
||||
|
||||
function renderSelectedParents() {
|
||||
editParentList.innerHTML = '';
|
||||
selectedParents.forEach(function (parent) {
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'secret-chip';
|
||||
|
||||
var name = document.createElement('span');
|
||||
name.className = 'secret-name';
|
||||
name.textContent = parent.name;
|
||||
|
||||
var type = document.createElement('span');
|
||||
type.className = 'secret-type';
|
||||
type.textContent = parent.entry_type || parent.type || '';
|
||||
|
||||
var remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'btn-icon btn-view-unlink';
|
||||
remove.textContent = '×';
|
||||
remove.title = t('removeParent');
|
||||
remove.addEventListener('click', function () {
|
||||
selectedParents = selectedParents.filter(function (item) { return item.id !== parent.id; });
|
||||
renderSelectedParents();
|
||||
});
|
||||
|
||||
chip.appendChild(name);
|
||||
chip.appendChild(type);
|
||||
chip.appendChild(remove);
|
||||
editParentList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderParentSearchResults(options) {
|
||||
editParentResults.innerHTML = '';
|
||||
if (!options.length) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'view-locked-msg';
|
||||
empty.textContent = t('parentSearchEmpty');
|
||||
editParentResults.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
options.forEach(function (option) {
|
||||
if (selectedParents.some(function (item) { return item.id === String(option.id); })) return;
|
||||
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'secret-chip';
|
||||
button.style.background = '#161b22';
|
||||
button.style.cursor = 'pointer';
|
||||
button.innerHTML = '<span class="secret-name"></span><span class="secret-type"></span>';
|
||||
button.querySelector('.secret-name').textContent = option.name;
|
||||
button.querySelector('.secret-type').textContent = [option.folder, option.type].filter(Boolean).join(' / ');
|
||||
button.addEventListener('click', function () {
|
||||
selectedParents.push({
|
||||
id: String(option.id),
|
||||
name: option.name,
|
||||
folder: option.folder || '',
|
||||
entry_type: option.type || ''
|
||||
});
|
||||
editParentSearch.value = '';
|
||||
editParentResults.innerHTML = '';
|
||||
renderSelectedParents();
|
||||
});
|
||||
editParentResults.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function searchParentEntries() {
|
||||
var query = editParentSearch.value.trim();
|
||||
if (!query || !currentEntryId) {
|
||||
editParentResults.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fetch('/api/entries/options?q=' + encodeURIComponent(query) + '&exclude_id=' + encodeURIComponent(currentEntryId), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
|
||||
return body;
|
||||
});
|
||||
}).then(function (options) {
|
||||
renderParentSearchResults(options || []);
|
||||
}).catch(function (error) {
|
||||
showEditErr(error.message || String(error));
|
||||
});
|
||||
}
|
||||
|
||||
// ── View secrets modal ────────────────────────────────────────────────────
|
||||
var viewOverlay = document.getElementById('view-overlay');
|
||||
@@ -1264,6 +1392,14 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
} catch (err) {
|
||||
editMetadata.value = md;
|
||||
}
|
||||
try {
|
||||
selectedParents = JSON.parse(tr.getAttribute('data-entry-parents') || '[]');
|
||||
} catch (err) {
|
||||
selectedParents = [];
|
||||
}
|
||||
editParentSearch.value = '';
|
||||
editParentResults.innerHTML = '';
|
||||
renderSelectedParents();
|
||||
editOverlay.hidden = false;
|
||||
}
|
||||
|
||||
@@ -1273,37 +1409,31 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
showEditErr('');
|
||||
editUpdatedAt.textContent = '';
|
||||
editUpdatedAt.title = '';
|
||||
editParentSearch.value = '';
|
||||
editParentResults.innerHTML = '';
|
||||
selectedParents = [];
|
||||
renderSelectedParents();
|
||||
}
|
||||
|
||||
editParentSearch.addEventListener('input', function () {
|
||||
clearTimeout(parentSearchTimer);
|
||||
parentSearchTimer = setTimeout(searchParentEntries, 180);
|
||||
});
|
||||
|
||||
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
|
||||
editOverlay.addEventListener('click', function (e) {
|
||||
if (e.target === editOverlay) closeEdit();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
|
||||
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
|
||||
if (e.key === 'Escape' && !viewOverlay.hidden) closeView();
|
||||
});
|
||||
|
||||
function showDeleteErr(msg) {
|
||||
deleteError.textContent = msg || '';
|
||||
deleteError.classList.toggle('visible', !!msg);
|
||||
}
|
||||
|
||||
function openDelete(id, name) {
|
||||
pendingDeleteId = id;
|
||||
deleteMessage.textContent = tf('deleteMessage', { name: name });
|
||||
showDeleteErr('');
|
||||
deleteOverlay.hidden = false;
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
deleteOverlay.hidden = true;
|
||||
pendingDeleteId = null;
|
||||
showDeleteErr('');
|
||||
}
|
||||
|
||||
function refreshListAfterSave(entryId, body) {
|
||||
if (body.parent_ids) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||||
if (!tr) { window.location.reload(); return; }
|
||||
var nameCell = tr.querySelector('.cell-name');
|
||||
@@ -1368,27 +1498,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('delete-cancel').addEventListener('click', closeDelete);
|
||||
deleteOverlay.addEventListener('click', function (e) {
|
||||
if (e.target === deleteOverlay) closeDelete();
|
||||
});
|
||||
document.getElementById('delete-confirm').addEventListener('click', function () {
|
||||
if (!pendingDeleteId) return;
|
||||
fetch('/api/entries/' + encodeURIComponent(pendingDeleteId), { method: 'DELETE', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
var deletedId = pendingDeleteId;
|
||||
closeDelete();
|
||||
refreshListAfterDelete(deletedId);
|
||||
})
|
||||
.catch(function (e) { showDeleteErr(e.message || String(e)); });
|
||||
});
|
||||
|
||||
document.getElementById('edit-save').addEventListener('click', function () {
|
||||
if (!currentEntryId) return;
|
||||
var meta;
|
||||
@@ -1409,7 +1518,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
name: editName.value.trim(),
|
||||
notes: editNotes.value,
|
||||
tags: tags,
|
||||
metadata: meta
|
||||
metadata: meta,
|
||||
parent_ids: selectedParents.map(function (parent) { return parent.id; })
|
||||
};
|
||||
showEditErr('');
|
||||
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
|
||||
@@ -1433,17 +1543,23 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
|
||||
var viewBtn = tr.querySelector('.btn-view-secrets');
|
||||
if (viewBtn) {
|
||||
var hasSecrets = tr.querySelectorAll('.secret-chip').length > 0;
|
||||
var hasSecrets = tr.querySelectorAll('.col-secrets .secret-chip').length > 0;
|
||||
if (!hasSecrets) viewBtn.disabled = true;
|
||||
viewBtn.addEventListener('click', function () { openView(tr); });
|
||||
}
|
||||
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
||||
tr.querySelector('.btn-del').addEventListener('click', function () {
|
||||
var id = tr.getAttribute('data-entry-id');
|
||||
var nameEl = tr.querySelector('.cell-name');
|
||||
var name = nameEl ? nameEl.textContent.trim() : '';
|
||||
if (!id) return;
|
||||
openDelete(id, name);
|
||||
fetch('/api/entries/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
})
|
||||
.then(function () { refreshListAfterDelete(id); })
|
||||
.catch(function (e) { alert(e.message || String(e)); });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ var I18N_SHARED = {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: '条目',
|
||||
navTrash: '回收站',
|
||||
navAudit: '审计',
|
||||
signOut: '退出',
|
||||
mobileLabelTime: '时间',
|
||||
@@ -14,6 +15,7 @@ var I18N_SHARED = {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: '條目',
|
||||
navTrash: '回收站',
|
||||
navAudit: '審計',
|
||||
signOut: '登出',
|
||||
mobileLabelTime: '時間',
|
||||
@@ -25,6 +27,7 @@ var I18N_SHARED = {
|
||||
pageTitleBase: 'Secrets',
|
||||
navMcp: 'MCP',
|
||||
navEntries: 'Entries',
|
||||
navTrash: 'Trash',
|
||||
navAudit: 'Audit',
|
||||
signOut: 'Sign out',
|
||||
mobileLabelTime: 'Time',
|
||||
|
||||
272
crates/secrets-mcp/templates/trash.html
Normal file
272
crates/secrets-mcp/templates/trash.html
Normal file
@@ -0,0 +1,272 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
|
||||
<title>Secrets — 回收站</title>
|
||||
<style>
|
||||
*, *::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');
|
||||
:root {
|
||||
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
|
||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||
.sidebar-menu { display: grid; gap: 6px; }
|
||||
.sidebar-link {
|
||||
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
|
||||
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
background: transparent; border-bottom: none; padding: 0 24px;
|
||||
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||
}
|
||||
.topbar-spacer { flex: 1; }
|
||||
.nav-user { font-size: 14px; color: #8b949e; }
|
||||
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||
.btn-sign-out {
|
||||
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.main { padding: 16px 16px 24px; flex: 1; }
|
||||
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||
padding: 20px; width: 100%; }
|
||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; color: #fff; }
|
||||
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); vertical-align: top; }
|
||||
th { color: #8b949e; font-size: 12px; font-weight: 600; }
|
||||
td { font-size: 13px; color: #c9d1d9; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn { border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #c9d1d9; border-radius: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; font-family: inherit; }
|
||||
.btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.btn-danger { color: #f85149; }
|
||||
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||
.pagination {
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 18px;
|
||||
justify-content: center; padding: 12px 0;
|
||||
}
|
||||
.page-btn {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||
.page-btn.disabled {
|
||||
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||
background: #161b22; color: #6e7681; font-size: 13px;
|
||||
opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.page-info {
|
||||
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.layout { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
|
||||
padding: 16px; gap: 14px;
|
||||
}
|
||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
|
||||
.main { padding: 20px 12px 28px; }
|
||||
.card { padding: 16px; }
|
||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||
table, thead, tbody, th, td, tr { display: block; }
|
||||
thead { display: none; }
|
||||
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
|
||||
td { border-top: none; padding: 6px 0; }
|
||||
td::before {
|
||||
display: block; color: #8b949e; font-size: 11px;
|
||||
margin-bottom: 4px; text-transform: uppercase;
|
||||
content: attr(data-label);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a href="/dashboard" class="sidebar-logo">secrets</a>
|
||||
<nav class="sidebar-menu">
|
||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||
<a href="/trash" class="sidebar-link active" data-i18n="navTrash">回收站</a>
|
||||
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="content-shell">
|
||||
<div class="topbar">
|
||||
<span class="topbar-spacer"></span>
|
||||
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
|
||||
<div class="lang-bar">
|
||||
<button class="lang-btn" onclick="setLang('zh-CN')">简</button>
|
||||
<button class="lang-btn" onclick="setLang('zh-TW')">繁</button>
|
||||
<button class="lang-btn" onclick="setLang('en')">EN</button>
|
||||
</div>
|
||||
<form action="/auth/logout" method="post" style="display:inline">
|
||||
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<section class="card">
|
||||
<div class="card-title" data-i18n="trashTitle">回收站</div>
|
||||
<div class="card-subtitle" data-i18n="trashSubtitle">已删除条目会保留 3 个月,可在此恢复或永久删除。</div>
|
||||
|
||||
{% if entries.is_empty() %}
|
||||
<div class="empty" data-i18n="emptyTrash">回收站为空。</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="colName">名称</th>
|
||||
<th data-i18n="colType">类型</th>
|
||||
<th data-i18n="colFolder">文件夹</th>
|
||||
<th data-i18n="colDeletedAt">删除时间</th>
|
||||
<th data-i18n="colActions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr data-trash-entry-id="{{ entry.id }}">
|
||||
<td class="mono" data-label="名称">{{ entry.name }}</td>
|
||||
<td class="mono" data-label="类型">{{ entry.entry_type }}</td>
|
||||
<td class="mono" data-label="文件夹">{{ entry.folder }}</td>
|
||||
<td class="mono" data-label="删除时间" title="{{ entry.deleted_at_iso }}">{{ entry.deleted_at_label }}</td>
|
||||
<td data-label="操作">
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn btn-restore" data-i18n="btnRestore">恢复</button>
|
||||
<button type="button" class="btn btn-danger btn-purge" data-i18n="btnPurge">永久删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a class="page-btn" href="/trash?page={{ current_page - 1 }}" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a class="page-btn" href="/trash?page={{ current_page + 1 }}" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
I18N_PAGE = {
|
||||
'zh-CN': {
|
||||
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
|
||||
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。',
|
||||
emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹',
|
||||
colDeletedAt: '删除时间', colActions: '操作', btnRestore: '恢复', btnPurge: '永久删除',
|
||||
prevPage: '上一页', nextPage: '下一页',
|
||||
mobileLabelName: '名称', mobileLabelType: '类型', mobileLabelFolder: '文件夹',
|
||||
mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作'
|
||||
},
|
||||
'zh-TW': {
|
||||
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。',
|
||||
emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾',
|
||||
colDeletedAt: '刪除時間', colActions: '操作', btnRestore: '恢復', btnPurge: '永久刪除',
|
||||
prevPage: '上一頁', nextPage: '下一頁',
|
||||
mobileLabelName: '名稱', mobileLabelType: '類型', mobileLabelFolder: '文件夾',
|
||||
mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作'
|
||||
},
|
||||
en: {
|
||||
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.',
|
||||
emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder',
|
||||
colDeletedAt: 'Deleted at', colActions: 'Actions', btnRestore: 'Restore', btnPurge: 'Purge',
|
||||
prevPage: 'Previous', nextPage: 'Next',
|
||||
mobileLabelName: 'Name', mobileLabelType: 'Type', mobileLabelFolder: 'Folder',
|
||||
mobileLabelDeletedAt: 'Deleted at', mobileLabelActions: 'Actions'
|
||||
}
|
||||
};
|
||||
|
||||
window.applyPageLang = function () {
|
||||
document.querySelectorAll('tbody tr').forEach(function (tr) {
|
||||
['Name', 'Type', 'Folder', 'DeletedAt', 'Actions'].forEach(function (col) {
|
||||
var td = tr.querySelector('[data-label]');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
applyLang();
|
||||
})();
|
||||
|
||||
document.querySelectorAll('tr[data-trash-entry-id]').forEach(function (row) {
|
||||
var entryId = row.getAttribute('data-trash-entry-id');
|
||||
var restoreButton = row.querySelector('.btn-restore');
|
||||
var purgeButton = row.querySelector('.btn-purge');
|
||||
|
||||
restoreButton.addEventListener('click', function () {
|
||||
fetch('/api/trash/' + encodeURIComponent(entryId) + '/restore', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
|
||||
return body;
|
||||
});
|
||||
}).then(function () {
|
||||
row.remove();
|
||||
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
|
||||
}).catch(function (error) {
|
||||
window.alert(error.message || String(error));
|
||||
});
|
||||
});
|
||||
|
||||
purgeButton.addEventListener('click', function () {
|
||||
if (!window.confirm(t('confirmPurge') || '确定永久删除该条目?此操作不可撤销。')) return;
|
||||
fetch('/api/trash/' + encodeURIComponent(entryId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
|
||||
return body;
|
||||
});
|
||||
}).then(function () {
|
||||
row.remove();
|
||||
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
|
||||
}).catch(function (error) {
|
||||
window.alert(error.message || String(error));
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user