style(dashboard): move version footer out of card
This commit is contained in:
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user