style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s

This commit is contained in:
agent
2026-04-09 15:23:16 +08:00
parent 10da51c203
commit 089d0b4b58
23 changed files with 2114 additions and 525 deletions

View File

@@ -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,
})))
}