style(dashboard): move version footer out of card
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2065,7 +2065,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.12"
|
version = "0.5.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
|||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
version BIGINT NOT NULL DEFAULT 1,
|
version BIGINT NOT NULL DEFAULT 1,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Legacy unique constraint without user_id (single-user mode)
|
-- Legacy unique constraint without user_id (single-user mode)
|
||||||
|
-- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support.
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||||
ON entries(folder, name)
|
ON entries(folder, name)
|
||||||
WHERE user_id IS NULL;
|
WHERE user_id IS NULL;
|
||||||
@@ -127,6 +129,17 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
|
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
|
||||||
|
|
||||||
|
-- ── entry_relations: parent-child links between entries ──────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||||
|
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY(parent_entry_id, child_entry_id),
|
||||||
|
CHECK (parent_entry_id <> child_entry_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||||
|
|
||||||
-- ── audit_log: append-only operation log ─────────────────────────────────
|
-- ── audit_log: append-only operation log ─────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
@@ -170,6 +183,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
|
|||||||
|
|
||||||
-- Backfill: add notes to entries if not present (fresh installs already have it)
|
-- Backfill: add notes to entries if not present (fresh installs already have it)
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
-- ── secrets_history: field-level snapshot ────────────────────────────────
|
-- ── secrets_history: field-level snapshot ────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS secrets_history (
|
CREATE TABLE IF NOT EXISTS secrets_history (
|
||||||
@@ -404,11 +418,11 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
|||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
||||||
ON entries(folder, name)
|
ON entries(folder, name)
|
||||||
WHERE user_id IS NULL;
|
WHERE user_id IS NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
||||||
ON entries(user_id, folder, name)
|
ON entries(user_id, folder, name)
|
||||||
WHERE user_id IS NOT NULL;
|
WHERE user_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
-- ── Replace old namespace/kind indexes ────────────────────────────────────
|
-- ── Replace old namespace/kind indexes ────────────────────────────────────
|
||||||
DROP INDEX IF EXISTS idx_entries_namespace;
|
DROP INDEX IF EXISTS idx_entries_namespace;
|
||||||
@@ -420,6 +434,8 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
|||||||
ON entries(folder) WHERE folder <> '';
|
ON entries(folder) WHERE folder <> '';
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_type
|
CREATE INDEX IF NOT EXISTS idx_entries_type
|
||||||
ON entries(type) WHERE type <> '';
|
ON entries(type) WHERE type <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
|
||||||
|
ON entries(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
|
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
|
||||||
ON audit_log(folder, type);
|
ON audit_log(folder, type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct Entry {
|
|||||||
pub version: i64,
|
pub version: i64,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single encrypted field belonging to an Entry.
|
/// A single encrypted field belonging to an Entry.
|
||||||
@@ -52,6 +53,7 @@ pub struct EntryRow {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry row including `name` (used for id-scoped web / service updates).
|
/// Entry row including `name` (used for id-scoped web / service updates).
|
||||||
@@ -66,6 +68,7 @@ pub struct EntryWriteRow {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&EntryWriteRow> for EntryRow {
|
impl From<&EntryWriteRow> for EntryRow {
|
||||||
@@ -78,6 +81,7 @@ impl From<&EntryWriteRow> for EntryRow {
|
|||||||
tags: r.tags.clone(),
|
tags: r.tags.clone(),
|
||||||
metadata: r.metadata.clone(),
|
metadata: r.metadata.clone(),
|
||||||
notes: r.notes.clone(),
|
notes: r.notes.clone(),
|
||||||
|
name: r.name.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
|||||||
// Fetch existing entry by (user_id, folder, name) — the natural unique key
|
// Fetch existing entry by (user_id, folder, name) — the natural unique key
|
||||||
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
|
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||||
WHERE user_id = $1 AND folder = $2 AND name = $3",
|
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.bind(params.folder)
|
.bind(params.folder)
|
||||||
@@ -223,8 +223,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||||
WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(params.folder)
|
.bind(params.folder)
|
||||||
.bind(params.name)
|
.bind(params.name)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use sqlx::PgPool;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
||||||
use crate::service::util::user_scope_condition;
|
use crate::service::util::user_scope_condition;
|
||||||
|
|
||||||
@@ -21,6 +22,17 @@ pub struct DeleteResult {
|
|||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct TrashEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub folder: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
pub entry_type: String,
|
||||||
|
pub deleted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DeleteParams<'a> {
|
pub struct DeleteParams<'a> {
|
||||||
/// If set, delete a single entry by name.
|
/// If set, delete a single entry by name.
|
||||||
pub name: Option<&'a str>,
|
pub name: Option<&'a str>,
|
||||||
@@ -36,12 +48,156 @@ pub struct DeleteParams<'a> {
|
|||||||
/// Prevents accidental mass deletion when filters are too broad.
|
/// Prevents accidental mass deletion when filters are too broad.
|
||||||
pub const MAX_BULK_DELETE: usize = 1000;
|
pub const MAX_BULK_DELETE: usize = 1000;
|
||||||
|
|
||||||
|
pub async fn list_deleted_entries(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<TrashEntry>> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, name, folder, type, deleted_at FROM entries \
|
||||||
|
WHERE user_id = $1 AND deleted_at IS NOT NULL \
|
||||||
|
ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||||
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(entry_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = match row {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(AppError::NotFoundEntry.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let conflict_exists: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM entries \
|
||||||
|
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&row.folder)
|
||||||
|
.bind(&row.name)
|
||||||
|
.bind(row.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if conflict_exists {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(AppError::ConflictEntryName {
|
||||||
|
folder: row.folder,
|
||||||
|
name: row.name,
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(row.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::audit::log_tx(
|
||||||
|
&mut tx,
|
||||||
|
Some(user_id),
|
||||||
|
"restore",
|
||||||
|
&row.folder,
|
||||||
|
&row.entry_type,
|
||||||
|
&row.name,
|
||||||
|
json!({ "entry_id": row.id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||||
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(entry_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let row = match row {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(AppError::NotFoundEntry.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
purge_entry_record(&mut tx, row.id).await?;
|
||||||
|
crate::audit::log_tx(
|
||||||
|
&mut tx,
|
||||||
|
Some(user_id),
|
||||||
|
"purge",
|
||||||
|
&row.folder,
|
||||||
|
&row.entry_type,
|
||||||
|
&row.name,
|
||||||
|
json!({ "entry_id": row.id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result<u64> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ExpiredRow {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let rows: Vec<ExpiredRow> = sqlx::query_as(
|
||||||
|
"SELECT id FROM entries \
|
||||||
|
WHERE deleted_at IS NOT NULL \
|
||||||
|
AND deleted_at < NOW() - INTERVAL '3 months' \
|
||||||
|
FOR UPDATE",
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in &rows {
|
||||||
|
purge_entry_record(&mut tx, row.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(rows.len() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a single entry by id (multi-tenant: `user_id` must match).
|
/// Delete a single entry by id (multi-tenant: `user_id` must match).
|
||||||
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
|
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -61,7 +217,7 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
|
|||||||
let name = row.name.clone();
|
let name = row.name.clone();
|
||||||
let entry_row: EntryRow = (&row).into();
|
let entry_row: EntryRow = (&row).into();
|
||||||
|
|
||||||
snapshot_and_delete(
|
snapshot_and_soft_delete(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
&folder,
|
&folder,
|
||||||
&entry_type,
|
&entry_type,
|
||||||
@@ -141,7 +297,7 @@ async fn delete_one(
|
|||||||
}
|
}
|
||||||
conditions.push(format!("name = ${}", idx));
|
conditions.push(format!("name = ${}", idx));
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT folder, type FROM entries WHERE {}",
|
"SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL",
|
||||||
conditions.join(" AND ")
|
conditions.join(" AND ")
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
|
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
|
||||||
@@ -198,7 +354,8 @@ async fn delete_one(
|
|||||||
}
|
}
|
||||||
conditions.push(format!("name = ${}", idx));
|
conditions.push(format!("name = ${}", idx));
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
|
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||||
|
WHERE {} AND deleted_at IS NULL FOR UPDATE",
|
||||||
conditions.join(" AND ")
|
conditions.join(" AND ")
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||||
@@ -238,7 +395,7 @@ async fn delete_one(
|
|||||||
|
|
||||||
let folder = row.folder.clone();
|
let folder = row.folder.clone();
|
||||||
let entry_type = row.entry_type.clone();
|
let entry_type = row.entry_type.clone();
|
||||||
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
||||||
crate::audit::log_tx(
|
crate::audit::log_tx(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -305,7 +462,7 @@ async fn delete_bulk(
|
|||||||
if dry_run {
|
if dry_run {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||||
FROM entries {where_clause} ORDER BY type, name"
|
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name"
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||||
if let Some(uid) = user_id {
|
if let Some(uid) = user_id {
|
||||||
@@ -337,7 +494,7 @@ async fn delete_bulk(
|
|||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
||||||
FROM entries {where_clause} ORDER BY type, name FOR UPDATE"
|
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE"
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
||||||
if let Some(uid) = user_id {
|
if let Some(uid) = user_id {
|
||||||
@@ -371,8 +528,9 @@ async fn delete_bulk(
|
|||||||
tags: row.tags.clone(),
|
tags: row.tags.clone(),
|
||||||
metadata: row.metadata.clone(),
|
metadata: row.metadata.clone(),
|
||||||
notes: row.notes.clone(),
|
notes: row.notes.clone(),
|
||||||
|
name: row.name.clone(),
|
||||||
};
|
};
|
||||||
snapshot_and_delete(
|
snapshot_and_soft_delete(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
&row.folder,
|
&row.folder,
|
||||||
&row.entry_type,
|
&row.entry_type,
|
||||||
@@ -406,7 +564,7 @@ async fn delete_bulk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn snapshot_and_delete(
|
async fn snapshot_and_soft_delete(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
entry_type: &str,
|
entry_type: &str,
|
||||||
@@ -468,11 +626,33 @@ async fn snapshot_and_delete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1")
|
||||||
.bind(row.id)
|
.bind(row.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn purge_entry_record(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
entry_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
let fields: Vec<SecretFieldRow> = sqlx::query_as(
|
||||||
|
"SELECT s.id, s.name, s.encrypted \
|
||||||
|
FROM entry_secrets es \
|
||||||
|
JOIN secrets s ON s.id = es.secret_id \
|
||||||
|
WHERE es.entry_id = $1",
|
||||||
|
)
|
||||||
|
.bind(entry_id)
|
||||||
|
.fetch_all(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM entries WHERE id = $1")
|
||||||
|
.bind(entry_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
|
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
|
||||||
if !secret_ids.is_empty() {
|
if !secret_ids.is_empty() {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub async fn build_env_map(
|
|||||||
master_key: &[u8; 32],
|
master_key: &[u8; 32],
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
) -> Result<HashMap<String, String>> {
|
) -> Result<HashMap<String, String>> {
|
||||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
|
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, None, user_id).await?;
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub async fn export(
|
|||||||
params.name,
|
params.name,
|
||||||
params.tags,
|
params.tags,
|
||||||
params.query,
|
params.query,
|
||||||
|
None,
|
||||||
params.user_id,
|
params.user_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod export;
|
|||||||
pub mod get_secret;
|
pub mod get_secret;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod relations;
|
||||||
pub mod rollback;
|
pub mod rollback;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|||||||
324
crates/secrets-core/src/service/relations.rs
Normal file
324
crates/secrets-core/src/service/relations.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct RelationEntrySummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub folder: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
pub entry_type: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
|
pub struct EntryRelations {
|
||||||
|
pub parents: Vec<RelationEntrySummary>,
|
||||||
|
pub children: Vec<RelationEntrySummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_parent_relation(
|
||||||
|
pool: &PgPool,
|
||||||
|
parent_entry_id: Uuid,
|
||||||
|
child_entry_id: Uuid,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if parent_entry_id == child_entry_id {
|
||||||
|
return Err(AppError::Validation {
|
||||||
|
message: "entry cannot reference itself".to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||||
|
|
||||||
|
let cycle_exists: bool = sqlx::query_scalar(
|
||||||
|
"WITH RECURSIVE descendants AS ( \
|
||||||
|
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||||
|
UNION \
|
||||||
|
SELECT er.child_entry_id \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||||
|
) \
|
||||||
|
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.bind(parent_entry_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if cycle_exists {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(AppError::Validation {
|
||||||
|
message: "adding this relation would create a cycle".to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) \
|
||||||
|
VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(parent_entry_id)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_parent_relation(
|
||||||
|
pool: &PgPool,
|
||||||
|
parent_entry_id: Uuid,
|
||||||
|
child_entry_id: Uuid,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
|
||||||
|
sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2")
|
||||||
|
.bind(parent_entry_id)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_parent_relations(
|
||||||
|
pool: &PgPool,
|
||||||
|
child_entry_id: Uuid,
|
||||||
|
parent_entry_ids: &[Uuid],
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let deduped: Vec<Uuid> = parent_entry_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
if deduped.contains(&child_entry_id) {
|
||||||
|
return Err(AppError::Validation {
|
||||||
|
message: "entry cannot reference itself".to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let mut validate_ids = Vec::with_capacity(deduped.len() + 1);
|
||||||
|
validate_ids.push(child_entry_id);
|
||||||
|
validate_ids.extend(deduped.iter().copied());
|
||||||
|
validate_live_entries(&mut tx, &validate_ids, user_id).await?;
|
||||||
|
|
||||||
|
let current_parent_ids: Vec<Uuid> =
|
||||||
|
sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1")
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let current: BTreeSet<Uuid> = current_parent_ids.into_iter().collect();
|
||||||
|
let target: BTreeSet<Uuid> = deduped.iter().copied().collect();
|
||||||
|
|
||||||
|
for parent_id in current.difference(&target) {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2",
|
||||||
|
)
|
||||||
|
.bind(*parent_id)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for parent_id in target.difference(¤t) {
|
||||||
|
let cycle_exists: bool = sqlx::query_scalar(
|
||||||
|
"WITH RECURSIVE descendants AS ( \
|
||||||
|
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
|
||||||
|
UNION \
|
||||||
|
SELECT er.child_entry_id \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
|
||||||
|
) \
|
||||||
|
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.bind(*parent_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if cycle_exists {
|
||||||
|
tx.rollback().await?;
|
||||||
|
return Err(AppError::Validation {
|
||||||
|
message: "adding this relation would create a cycle".to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \
|
||||||
|
ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(*parent_id)
|
||||||
|
.bind(child_entry_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_relations_for_entries(
|
||||||
|
pool: &PgPool,
|
||||||
|
entry_ids: &[Uuid],
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<HashMap<Uuid, EntryRelations>> {
|
||||||
|
if entry_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ParentRow {
|
||||||
|
owner_entry_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
folder: String,
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
entry_type: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ChildRow {
|
||||||
|
owner_entry_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
folder: String,
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
entry_type: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let (parents, children): (Vec<ParentRow>, Vec<ChildRow>) = if let Some(uid) = user_id {
|
||||||
|
let parents = sqlx::query_as(
|
||||||
|
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN entries p ON p.id = er.parent_entry_id \
|
||||||
|
JOIN entries c ON c.id = er.child_entry_id \
|
||||||
|
WHERE er.child_entry_id = ANY($1) \
|
||||||
|
AND p.user_id = $2 AND c.user_id = $2 \
|
||||||
|
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||||
|
ORDER BY er.child_entry_id, p.name ASC",
|
||||||
|
)
|
||||||
|
.bind(entry_ids)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_all(pool);
|
||||||
|
let children = sqlx::query_as(
|
||||||
|
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN entries c ON c.id = er.child_entry_id \
|
||||||
|
JOIN entries p ON p.id = er.parent_entry_id \
|
||||||
|
WHERE er.parent_entry_id = ANY($1) \
|
||||||
|
AND p.user_id = $2 AND c.user_id = $2 \
|
||||||
|
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||||
|
ORDER BY er.parent_entry_id, c.name ASC",
|
||||||
|
)
|
||||||
|
.bind(entry_ids)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_all(pool);
|
||||||
|
(parents.await?, children.await?)
|
||||||
|
} else {
|
||||||
|
let parents = sqlx::query_as(
|
||||||
|
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN entries p ON p.id = er.parent_entry_id \
|
||||||
|
JOIN entries c ON c.id = er.child_entry_id \
|
||||||
|
WHERE er.child_entry_id = ANY($1) \
|
||||||
|
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||||
|
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||||
|
ORDER BY er.child_entry_id, p.name ASC",
|
||||||
|
)
|
||||||
|
.bind(entry_ids)
|
||||||
|
.fetch_all(pool);
|
||||||
|
let children = sqlx::query_as(
|
||||||
|
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
|
||||||
|
FROM entry_relations er \
|
||||||
|
JOIN entries c ON c.id = er.child_entry_id \
|
||||||
|
JOIN entries p ON p.id = er.parent_entry_id \
|
||||||
|
WHERE er.parent_entry_id = ANY($1) \
|
||||||
|
AND p.user_id IS NULL AND c.user_id IS NULL \
|
||||||
|
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
|
||||||
|
ORDER BY er.parent_entry_id, c.name ASC",
|
||||||
|
)
|
||||||
|
.bind(entry_ids)
|
||||||
|
.fetch_all(pool);
|
||||||
|
(parents.await?, children.await?)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut map: HashMap<Uuid, EntryRelations> = entry_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|id| (id, EntryRelations::default()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for row in parents {
|
||||||
|
map.entry(row.owner_entry_id)
|
||||||
|
.or_default()
|
||||||
|
.parents
|
||||||
|
.push(RelationEntrySummary {
|
||||||
|
id: row.id,
|
||||||
|
folder: row.folder,
|
||||||
|
entry_type: row.entry_type,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in children {
|
||||||
|
map.entry(row.owner_entry_id)
|
||||||
|
.or_default()
|
||||||
|
.children
|
||||||
|
.push(RelationEntrySummary {
|
||||||
|
id: row.id,
|
||||||
|
folder: row.folder,
|
||||||
|
entry_type: row.entry_type,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_live_entries(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
entry_ids: &[Uuid],
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let unique_ids: Vec<Uuid> = entry_ids
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let live_count: i64 = if let Some(uid) = user_id {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*)::bigint FROM entries \
|
||||||
|
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&unique_ids)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*)::bigint FROM entries \
|
||||||
|
WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&unique_ids)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
if live_count != unique_ids.len() as i64 {
|
||||||
|
return Err(AppError::NotFoundEntry.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use sqlx::PgPool;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::EntryWriteRow;
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct RollbackResult {
|
pub struct RollbackResult {
|
||||||
@@ -17,11 +19,9 @@ pub struct RollbackResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
|
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
|
||||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
name: &str,
|
entry_id: Uuid,
|
||||||
folder: Option<&str>,
|
|
||||||
to_version: Option<i64>,
|
to_version: Option<i64>,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
) -> Result<RollbackResult> {
|
) -> Result<RollbackResult> {
|
||||||
@@ -36,88 +36,26 @@ pub async fn run(
|
|||||||
metadata: Value,
|
metadata: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disambiguate: find the unique entry_id for (name, folder).
|
let live_entry: Option<EntryWriteRow> = if let Some(uid) = user_id {
|
||||||
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
|
sqlx::query_as(
|
||||||
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
if let Some(f) = folder {
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||||
sqlx::query_scalar(
|
|
||||||
"SELECT DISTINCT entry_id FROM entries_history \
|
|
||||||
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
|
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(entry_id)
|
||||||
.bind(f)
|
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
sqlx::query_as(
|
||||||
"SELECT DISTINCT entry_id FROM entries_history \
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
WHERE name = $1 AND user_id = $2",
|
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(name)
|
.bind(entry_id)
|
||||||
.bind(uid)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
match ids.len() {
|
|
||||||
0 => None,
|
|
||||||
1 => Some(ids[0]),
|
|
||||||
_ => {
|
|
||||||
let folders: Vec<String> = sqlx::query_scalar(
|
|
||||||
"SELECT DISTINCT folder FROM entries_history \
|
|
||||||
WHERE name = $1 AND user_id = $2",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.bind(uid)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(f) = folder {
|
|
||||||
sqlx::query_scalar(
|
|
||||||
"SELECT DISTINCT entry_id FROM entries_history \
|
|
||||||
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.bind(f)
|
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
|
||||||
let ids: Vec<Uuid> = sqlx::query_scalar(
|
|
||||||
"SELECT DISTINCT entry_id FROM entries_history \
|
|
||||||
WHERE name = $1 AND user_id IS NULL",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
match ids.len() {
|
|
||||||
0 => None,
|
|
||||||
1 => Some(ids[0]),
|
|
||||||
_ => {
|
|
||||||
let folders: Vec<String> = sqlx::query_scalar(
|
|
||||||
"SELECT DISTINCT folder FROM entries_history \
|
|
||||||
WHERE name = $1 AND user_id IS NULL",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: entries named '{}' exist in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
|
let live_entry = live_entry.ok_or(AppError::NotFoundEntry)?;
|
||||||
|
|
||||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
@@ -142,8 +80,8 @@ pub async fn run(
|
|||||||
|
|
||||||
let snap = snap.ok_or_else(|| {
|
let snap = snap.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"No history found for '{}'{}.",
|
"No history found for entry '{}'{}.",
|
||||||
name,
|
live_entry.name,
|
||||||
to_version
|
to_version
|
||||||
.map(|v| format!(" at version {}", v))
|
.map(|v| format!(" at version {}", v))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -155,21 +93,9 @@ pub async fn run(
|
|||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
let live: Option<EntryWriteRow> = sqlx::query_as(
|
||||||
struct LiveEntry {
|
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
||||||
id: Uuid,
|
WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
|
||||||
version: i64,
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: String,
|
|
||||||
tags: Vec<String>,
|
|
||||||
metadata: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock the live entry if it exists (matched by entry_id for precision).
|
|
||||||
let live: Option<LiveEntry> = sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata FROM entries \
|
|
||||||
WHERE id = $1 FOR UPDATE",
|
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
@@ -192,7 +118,7 @@ pub async fn run(
|
|||||||
user_id,
|
user_id,
|
||||||
folder: &lr.folder,
|
folder: &lr.folder,
|
||||||
entry_type: &lr.entry_type,
|
entry_type: &lr.entry_type,
|
||||||
name,
|
name: &lr.name,
|
||||||
version: lr.version,
|
version: lr.version,
|
||||||
action: "rollback",
|
action: "rollback",
|
||||||
tags: &lr.tags,
|
tags: &lr.tags,
|
||||||
@@ -237,11 +163,13 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \
|
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
||||||
updated_at = NOW() WHERE id = $5",
|
version = version + 1, updated_at = NOW() WHERE id = $7",
|
||||||
)
|
)
|
||||||
.bind(&snap.folder)
|
.bind(&snap.folder)
|
||||||
.bind(&snap.entry_type)
|
.bind(&snap.entry_type)
|
||||||
|
.bind(&live_entry.name)
|
||||||
|
.bind(&live_entry.notes)
|
||||||
.bind(&snap.tags)
|
.bind(&snap.tags)
|
||||||
.bind(&snap_metadata)
|
.bind(&snap_metadata)
|
||||||
.bind(lr.id)
|
.bind(lr.id)
|
||||||
@@ -250,36 +178,7 @@ pub async fn run(
|
|||||||
|
|
||||||
lr.id
|
lr.id
|
||||||
} else {
|
} else {
|
||||||
if let Some(uid) = user_id {
|
return Err(AppError::NotFoundEntry.into());
|
||||||
sqlx::query_scalar(
|
|
||||||
"INSERT INTO entries \
|
|
||||||
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(&snap.folder)
|
|
||||||
.bind(&snap.entry_type)
|
|
||||||
.bind(name)
|
|
||||||
.bind(&snap.tags)
|
|
||||||
.bind(&snap_metadata)
|
|
||||||
.bind(snap.version)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(
|
|
||||||
"INSERT INTO entries \
|
|
||||||
(folder, type, name, notes, tags, metadata, version, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(&snap.folder)
|
|
||||||
.bind(&snap.entry_type)
|
|
||||||
.bind(name)
|
|
||||||
.bind(&snap.tags)
|
|
||||||
.bind(&snap_metadata)
|
|
||||||
.bind(snap.version)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(secret_snapshot) = snap_secret_snapshot {
|
if let Some(secret_snapshot) = snap_secret_snapshot {
|
||||||
@@ -292,8 +191,9 @@ pub async fn run(
|
|||||||
"rollback",
|
"rollback",
|
||||||
&snap.folder,
|
&snap.folder,
|
||||||
&snap.entry_type,
|
&snap.entry_type,
|
||||||
name,
|
&live_entry.name,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"entry_id": entry_id,
|
||||||
"restored_version": snap.version,
|
"restored_version": snap.version,
|
||||||
"original_action": snap.action,
|
"original_action": snap.action,
|
||||||
}),
|
}),
|
||||||
@@ -303,7 +203,7 @@ pub async fn run(
|
|||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(RollbackResult {
|
Ok(RollbackResult {
|
||||||
name: name.to_string(),
|
name: live_entry.name,
|
||||||
folder: snap.folder,
|
folder: snap.folder,
|
||||||
entry_type: snap.entry_type,
|
entry_type: snap.entry_type,
|
||||||
restored_version: snap.version,
|
restored_version: snap.version,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub struct SearchParams<'a> {
|
|||||||
pub name_query: Option<&'a str>,
|
pub name_query: Option<&'a str>,
|
||||||
pub tags: &'a [String],
|
pub tags: &'a [String],
|
||||||
pub query: Option<&'a str>,
|
pub query: Option<&'a str>,
|
||||||
|
pub metadata_query: Option<&'a str>,
|
||||||
pub sort: &'a str,
|
pub sort: &'a str,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
@@ -75,6 +76,10 @@ pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
|
|||||||
let pattern = ilike_pattern(v);
|
let pattern = ilike_pattern(v);
|
||||||
q = q.bind(pattern);
|
q = q.bind(pattern);
|
||||||
}
|
}
|
||||||
|
if let Some(v) = a.metadata_query {
|
||||||
|
let pattern = ilike_pattern(v);
|
||||||
|
q = q.bind(pattern);
|
||||||
|
}
|
||||||
let n = q.fetch_one(pool).await?;
|
let n = q.fetch_one(pool).await?;
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
@@ -90,6 +95,7 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
|||||||
} else {
|
} else {
|
||||||
conditions.push("user_id IS NULL".to_string());
|
conditions.push("user_id IS NULL".to_string());
|
||||||
}
|
}
|
||||||
|
conditions.push("deleted_at IS NULL".to_string());
|
||||||
|
|
||||||
if a.folder.is_some() {
|
if a.folder.is_some() {
|
||||||
conditions.push(format!("folder = ${}", idx));
|
conditions.push(format!("folder = ${}", idx));
|
||||||
@@ -132,6 +138,14 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
|||||||
));
|
));
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
|
if a.metadata_query.is_some() {
|
||||||
|
conditions.push(format!(
|
||||||
|
"EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
|
||||||
|
WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')",
|
||||||
|
idx
|
||||||
|
));
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
let where_clause = if conditions.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -164,6 +178,7 @@ pub async fn fetch_entries(
|
|||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
query: Option<&str>,
|
query: Option<&str>,
|
||||||
|
metadata_query: Option<&str>,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
) -> Result<Vec<Entry>> {
|
) -> Result<Vec<Entry>> {
|
||||||
let params = SearchParams {
|
let params = SearchParams {
|
||||||
@@ -173,6 +188,7 @@ pub async fn fetch_entries(
|
|||||||
name_query: None,
|
name_query: None,
|
||||||
tags,
|
tags,
|
||||||
query,
|
query,
|
||||||
|
metadata_query,
|
||||||
sort: "name",
|
sort: "name",
|
||||||
limit: FETCH_ALL_LIMIT,
|
limit: FETCH_ALL_LIMIT,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -195,7 +211,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
|||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||||
created_at, updated_at \
|
created_at, updated_at, deleted_at \
|
||||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -223,6 +239,10 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
|||||||
let pattern = ilike_pattern(v);
|
let pattern = ilike_pattern(v);
|
||||||
q = q.bind(pattern);
|
q = q.bind(pattern);
|
||||||
}
|
}
|
||||||
|
if let Some(v) = a.metadata_query {
|
||||||
|
let pattern = ilike_pattern(v);
|
||||||
|
q = q.bind(pattern);
|
||||||
|
}
|
||||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
||||||
|
|
||||||
let rows = q.fetch_all(pool).await?;
|
let rows = q.fetch_all(pool).await?;
|
||||||
@@ -267,7 +287,7 @@ pub async fn resolve_entry_by_id(
|
|||||||
let row: Option<EntryRaw> = if let Some(uid) = user_id {
|
let row: Option<EntryRaw> = if let Some(uid) = user_id {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
|
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
@@ -276,7 +296,7 @@ pub async fn resolve_entry_by_id(
|
|||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
||||||
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
|
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -298,7 +318,7 @@ pub async fn resolve_entry(
|
|||||||
folder: Option<&str>,
|
folder: Option<&str>,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
) -> Result<crate::models::Entry> {
|
) -> Result<crate::models::Entry> {
|
||||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
|
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?;
|
||||||
match entries.len() {
|
match entries.len() {
|
||||||
0 => {
|
0 => {
|
||||||
if let Some(f) = folder {
|
if let Some(f) = folder {
|
||||||
@@ -339,6 +359,7 @@ struct EntryRaw {
|
|||||||
version: i64,
|
version: i64,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<EntryRaw> for Entry {
|
impl From<EntryRaw> for Entry {
|
||||||
@@ -355,6 +376,7 @@ impl From<EntryRaw> for Entry {
|
|||||||
version: r.version,
|
version: r.version,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
|
deleted_at: r.deleted_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
conditions.push(format!("name = ${}", idx));
|
conditions.push(format!("name = ${}", idx));
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
|
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
|
||||||
|
WHERE {} AND deleted_at IS NULL FOR UPDATE",
|
||||||
conditions.join(" AND ")
|
conditions.join(" AND ")
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||||
@@ -464,7 +465,7 @@ pub async fn update_fields_by_id(
|
|||||||
|
|
||||||
let row: Option<EntryWriteRow> = sqlx::query_as(
|
let row: Option<EntryWriteRow> = sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
|
||||||
WHERE id = $1 AND user_id = $2 FOR UPDATE",
|
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.12"
|
version = "0.5.13"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use tracing_subscriber::fmt::time::FormatTime;
|
|||||||
|
|
||||||
use secrets_core::config::resolve_db_config;
|
use secrets_core::config::resolve_db_config;
|
||||||
use secrets_core::db::{create_pool, migrate};
|
use secrets_core::db::{create_pool, migrate};
|
||||||
|
use secrets_core::service::delete::purge_expired_deleted_entries;
|
||||||
|
|
||||||
use crate::oauth::OAuthConfig;
|
use crate::oauth::OAuthConfig;
|
||||||
use crate::tools::SecretsService;
|
use crate::tools::SecretsService;
|
||||||
@@ -169,6 +170,7 @@ async fn main() -> Result<()> {
|
|||||||
// Rate limiting
|
// Rate limiting
|
||||||
let rate_limit_state = rate_limit::RateLimitState::new();
|
let rate_limit_state = rate_limit::RateLimitState::new();
|
||||||
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
|
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()
|
let router = Router::new()
|
||||||
.merge(web::web_router())
|
.merge(web::web_router())
|
||||||
@@ -212,9 +214,28 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
session_cleanup.abort();
|
session_cleanup.abort();
|
||||||
rate_limit_cleanup.abort();
|
rate_limit_cleanup.abort();
|
||||||
|
recycle_bin_cleanup.abort();
|
||||||
Ok(())
|
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() {
|
async fn shutdown_signal() {
|
||||||
let ctrl_c = tokio::signal::ctrl_c();
|
let ctrl_c = tokio::signal::ctrl_c();
|
||||||
|
|
||||||
|
|||||||
@@ -168,8 +168,9 @@ use secrets_core::service::{
|
|||||||
export::{ExportParams, export as svc_export},
|
export::{ExportParams, export as svc_export},
|
||||||
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
|
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
|
||||||
history::run as svc_history,
|
history::run as svc_history,
|
||||||
|
relations::{add_parent_relation, get_relations_for_entries, remove_parent_relation},
|
||||||
rollback::run as svc_rollback,
|
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},
|
update::{UpdateParams, run as svc_update},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,6 +374,8 @@ struct FindInput {
|
|||||||
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
|
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
|
||||||
)]
|
)]
|
||||||
query: Option<String>,
|
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')")]
|
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
#[schemars(
|
#[schemars(
|
||||||
@@ -401,6 +404,8 @@ struct FindInput {
|
|||||||
struct SearchInput {
|
struct SearchInput {
|
||||||
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
|
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
|
||||||
query: Option<String>,
|
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')")]
|
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
#[schemars(
|
#[schemars(
|
||||||
@@ -486,6 +491,9 @@ struct AddInput {
|
|||||||
)]
|
)]
|
||||||
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||||
link_secret_names: Option<Vec<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. \
|
#[schemars(description = "Encryption key as a 64-char hex string. \
|
||||||
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
||||||
Use this when the MCP client cannot reliably forward custom headers.")]
|
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")]
|
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
|
||||||
unlink_secret_names: Option<Vec<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. \
|
#[schemars(description = "Encryption key as a 64-char hex string. \
|
||||||
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
If provided, takes priority over the X-Encryption-Key HTTP header. \
|
||||||
Use this when the MCP client cannot reliably forward custom headers.")]
|
Use this when the MCP client cannot reliably forward custom headers.")]
|
||||||
@@ -596,16 +610,8 @@ struct HistoryInput {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct RollbackInput {
|
struct RollbackInput {
|
||||||
#[schemars(description = "Name of the entry")]
|
#[schemars(description = "Entry UUID (from secrets_find) for an existing, non-deleted entry")]
|
||||||
name: String,
|
id: 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 = "Target version number. Omit to restore the most recent snapshot.")]
|
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
|
||||||
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
|
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
|
||||||
to_version: Option<i64>,
|
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))
|
.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 implementations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tool_router]
|
#[tool_router]
|
||||||
@@ -752,6 +762,7 @@ impl SecretsService {
|
|||||||
name = input.name.as_deref(),
|
name = input.name.as_deref(),
|
||||||
name_query = input.name_query.as_deref(),
|
name_query = input.name_query.as_deref(),
|
||||||
query = input.query.as_deref(),
|
query = input.query.as_deref(),
|
||||||
|
metadata_query = input.metadata_query.as_deref(),
|
||||||
"tool call start",
|
"tool call start",
|
||||||
);
|
);
|
||||||
let tags = input.tags.unwrap_or_default();
|
let tags = input.tags.unwrap_or_default();
|
||||||
@@ -764,6 +775,7 @@ impl SecretsService {
|
|||||||
name_query: input.name_query.as_deref(),
|
name_query: input.name_query.as_deref(),
|
||||||
tags: &tags,
|
tags: &tags,
|
||||||
query: input.query.as_deref(),
|
query: input.query.as_deref(),
|
||||||
|
metadata_query: input.metadata_query.as_deref(),
|
||||||
sort: "name",
|
sort: "name",
|
||||||
limit: input.limit.unwrap_or(20),
|
limit: input.limit.unwrap_or(20),
|
||||||
offset: input.offset.unwrap_or(0),
|
offset: input.offset.unwrap_or(0),
|
||||||
@@ -780,6 +792,7 @@ impl SecretsService {
|
|||||||
name_query: input.name_query.as_deref(),
|
name_query: input.name_query.as_deref(),
|
||||||
tags: &tags,
|
tags: &tags,
|
||||||
query: input.query.as_deref(),
|
query: input.query.as_deref(),
|
||||||
|
metadata_query: input.metadata_query.as_deref(),
|
||||||
sort: "name",
|
sort: "name",
|
||||||
limit: 0,
|
limit: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -792,11 +805,23 @@ impl SecretsService {
|
|||||||
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
|
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
|
||||||
)
|
)
|
||||||
.unwrap_or(0);
|
.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
|
let entries: Vec<serde_json::Value> = result
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||||
let schema: Vec<serde_json::Value> = result
|
let schema: Vec<serde_json::Value> = result
|
||||||
.secret_schemas
|
.secret_schemas
|
||||||
.get(&e.id)
|
.get(&e.id)
|
||||||
@@ -819,6 +844,8 @@ impl SecretsService {
|
|||||||
"type": e.entry_type,
|
"type": e.entry_type,
|
||||||
"tags": e.tags,
|
"tags": e.tags,
|
||||||
"metadata": e.metadata,
|
"metadata": e.metadata,
|
||||||
|
"parents": relations.parents,
|
||||||
|
"children": relations.children,
|
||||||
"secret_fields": schema,
|
"secret_fields": schema,
|
||||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
"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 = input.name.as_deref(),
|
||||||
name_query = input.name_query.as_deref(),
|
name_query = input.name_query.as_deref(),
|
||||||
query = input.query.as_deref(),
|
query = input.query.as_deref(),
|
||||||
|
metadata_query = input.metadata_query.as_deref(),
|
||||||
"tool call start",
|
"tool call start",
|
||||||
);
|
);
|
||||||
let tags = input.tags.unwrap_or_default();
|
let tags = input.tags.unwrap_or_default();
|
||||||
@@ -879,6 +907,7 @@ impl SecretsService {
|
|||||||
name_query: input.name_query.as_deref(),
|
name_query: input.name_query.as_deref(),
|
||||||
tags: &tags,
|
tags: &tags,
|
||||||
query: input.query.as_deref(),
|
query: input.query.as_deref(),
|
||||||
|
metadata_query: input.metadata_query.as_deref(),
|
||||||
sort: input.sort.as_deref().unwrap_or("name"),
|
sort: input.sort.as_deref().unwrap_or("name"),
|
||||||
limit: input.limit.unwrap_or(20),
|
limit: input.limit.unwrap_or(20),
|
||||||
offset: input.offset.unwrap_or(0),
|
offset: input.offset.unwrap_or(0),
|
||||||
@@ -887,12 +916,24 @@ impl SecretsService {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
|
.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 summary = input.summary.unwrap_or(false);
|
||||||
let entries: Vec<serde_json::Value> = result
|
let entries: Vec<serde_json::Value> = result
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||||
if summary {
|
if summary {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"name": e.name,
|
"name": e.name,
|
||||||
@@ -900,6 +941,8 @@ impl SecretsService {
|
|||||||
"type": e.entry_type,
|
"type": e.entry_type,
|
||||||
"tags": e.tags,
|
"tags": e.tags,
|
||||||
"notes": e.notes,
|
"notes": e.notes,
|
||||||
|
"parents": relations.parents,
|
||||||
|
"children": relations.children,
|
||||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -926,6 +969,8 @@ impl SecretsService {
|
|||||||
"notes": e.notes,
|
"notes": e.notes,
|
||||||
"tags": e.tags,
|
"tags": e.tags,
|
||||||
"metadata": e.metadata,
|
"metadata": e.metadata,
|
||||||
|
"parents": relations.parents,
|
||||||
|
"children": relations.children,
|
||||||
"secret_fields": schema,
|
"secret_fields": schema,
|
||||||
"version": e.version,
|
"version": e.version,
|
||||||
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
"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())))
|
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
|
||||||
.collect();
|
.collect();
|
||||||
let link_secret_names = input.link_secret_names.unwrap_or_default();
|
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 folder = input.folder.as_deref().unwrap_or("");
|
||||||
let entry_type = input.entry_type.as_deref().unwrap_or("");
|
let entry_type = input.entry_type.as_deref().unwrap_or("");
|
||||||
let notes = input.notes.as_deref().unwrap_or("");
|
let notes = input.notes.as_deref().unwrap_or("");
|
||||||
@@ -1089,6 +1135,15 @@ impl SecretsService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
.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!(
|
tracing::info!(
|
||||||
tool = "secrets_add",
|
tool = "secrets_add",
|
||||||
?user_id,
|
?user_id,
|
||||||
@@ -1176,6 +1231,8 @@ impl SecretsService {
|
|||||||
let remove_secrets = input.remove_secrets.unwrap_or_default();
|
let remove_secrets = input.remove_secrets.unwrap_or_default();
|
||||||
let link_secret_names = input.link_secret_names.unwrap_or_default();
|
let link_secret_names = input.link_secret_names.unwrap_or_default();
|
||||||
let unlink_secret_names = input.unlink_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(
|
let result = svc_update(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
@@ -1199,6 +1256,30 @@ impl SecretsService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
|
.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!(
|
tracing::info!(
|
||||||
tool = "secrets_update",
|
tool = "secrets_update",
|
||||||
?user_id,
|
?user_id,
|
||||||
@@ -1354,30 +1435,13 @@ impl SecretsService {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
tool = "secrets_rollback",
|
tool = "secrets_rollback",
|
||||||
?user_id,
|
?user_id,
|
||||||
name = %input.name,
|
id = %input.id,
|
||||||
id = ?input.id,
|
|
||||||
to_version = input.to_version,
|
to_version = input.to_version,
|
||||||
"tool call start",
|
"tool call start",
|
||||||
);
|
);
|
||||||
|
let entry_id = parse_uuid(&input.id)?;
|
||||||
|
|
||||||
let (resolved_name, resolved_folder): (String, Option<String>) =
|
let result = svc_rollback(&self.pool, entry_id, input.to_version, Some(user_id))
|
||||||
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
|
.await
|
||||||
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
|
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use secrets_core::error::AppError;
|
use secrets_core::error::AppError;
|
||||||
use secrets_core::service::{
|
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,
|
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},
|
search::{SearchParams, count_entries, fetch_secrets_for_entries, ilike_pattern, list_entries},
|
||||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||||
};
|
};
|
||||||
@@ -40,6 +44,7 @@ struct EntriesPageTemplate {
|
|||||||
secret_type_options_json: String,
|
secret_type_options_json: String,
|
||||||
filter_folder: String,
|
filter_folder: String,
|
||||||
filter_name: String,
|
filter_name: String,
|
||||||
|
filter_metadata_query: String,
|
||||||
filter_type: String,
|
filter_type: String,
|
||||||
current_page: u32,
|
current_page: u32,
|
||||||
total_pages: u32,
|
total_pages: u32,
|
||||||
@@ -47,6 +52,18 @@ struct EntriesPageTemplate {
|
|||||||
version: &'static str,
|
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).
|
/// Non-sensitive entry fields; `secrets` lists field names/types only (no ciphertext).
|
||||||
struct EntryListItemView {
|
struct EntryListItemView {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -61,6 +78,9 @@ struct EntryListItemView {
|
|||||||
secrets: Vec<SecretSummaryView>,
|
secrets: Vec<SecretSummaryView>,
|
||||||
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
|
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
|
||||||
secrets_json: String,
|
secrets_json: String,
|
||||||
|
parents: Vec<RelationSummaryView>,
|
||||||
|
children: Vec<RelationSummaryView>,
|
||||||
|
parents_json: String,
|
||||||
/// RFC3339 UTC; shown in edit dialog.
|
/// RFC3339 UTC; shown in edit dialog.
|
||||||
updated_at_iso: String,
|
updated_at_iso: String,
|
||||||
}
|
}
|
||||||
@@ -72,6 +92,15 @@ struct SecretSummaryView {
|
|||||||
secret_type: String,
|
secret_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct RelationSummaryView {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
folder: String,
|
||||||
|
entry_type: String,
|
||||||
|
href: String,
|
||||||
|
}
|
||||||
|
|
||||||
struct FolderTabView {
|
struct FolderTabView {
|
||||||
name: String,
|
name: String,
|
||||||
count: i64,
|
count: i64,
|
||||||
@@ -79,16 +108,32 @@ struct FolderTabView {
|
|||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TrashEntryView {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
folder: String,
|
||||||
|
entry_type: String,
|
||||||
|
deleted_at_iso: String,
|
||||||
|
deleted_at_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct EntriesQuery {
|
pub(super) struct EntriesQuery {
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
metadata_query: Option<String>,
|
||||||
/// URL query key is `type` (maps to DB column `entries.type`).
|
/// URL query key is `type` (maps to DB column `entries.type`).
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
entry_type: Option<String>,
|
entry_type: Option<String>,
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct EntryOptionQuery {
|
||||||
|
q: Option<String>,
|
||||||
|
exclude_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Entry mutation error helpers ──────────────────────────────────────────────
|
// ── Entry mutation error helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
type EntryApiError = (StatusCode, Json<serde_json::Value>);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn entries_page(
|
pub(super) async fn entries_page(
|
||||||
@@ -202,6 +264,12 @@ pub(super) async fn entries_page(
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.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 page = q.page.unwrap_or(1).max(1);
|
||||||
let count_params = SearchParams {
|
let count_params = SearchParams {
|
||||||
folder: folder_filter.as_deref(),
|
folder: folder_filter.as_deref(),
|
||||||
@@ -210,6 +278,7 @@ pub(super) async fn entries_page(
|
|||||||
name_query: name_filter.as_deref(),
|
name_query: name_filter.as_deref(),
|
||||||
tags: &[],
|
tags: &[],
|
||||||
query: None,
|
query: None,
|
||||||
|
metadata_query: metadata_query_filter.as_deref(),
|
||||||
sort: "updated",
|
sort: "updated",
|
||||||
limit: ENTRIES_PAGE_LIMIT,
|
limit: ENTRIES_PAGE_LIMIT,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -223,7 +292,7 @@ pub(super) async fn entries_page(
|
|||||||
count: i64,
|
count: i64,
|
||||||
}
|
}
|
||||||
let mut folder_sql =
|
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;
|
let mut bind_idx = 2;
|
||||||
if type_filter.is_some() {
|
if type_filter.is_some() {
|
||||||
folder_sql.push_str(&format!(" AND type = ${bind_idx}"));
|
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 '\\'"));
|
folder_sql.push_str(&format!(" AND name ILIKE ${bind_idx} ESCAPE '\\'"));
|
||||||
bind_idx += 1;
|
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;
|
let _ = bind_idx;
|
||||||
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
|
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
|
||||||
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
|
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() {
|
if let Some(n) = name_filter.as_deref() {
|
||||||
folder_query = folder_query.bind(ilike_pattern(n));
|
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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct TypeOptionRow {
|
struct TypeOptionRow {
|
||||||
@@ -261,7 +340,7 @@ pub(super) async fn entries_page(
|
|||||||
},
|
},
|
||||||
folder_query.fetch_all(&state.pool),
|
folder_query.fetch_all(&state.pool),
|
||||||
sqlx::query_as::<_, TypeOptionRow>(
|
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)
|
.bind(user_id)
|
||||||
.fetch_all(&state.pool),
|
.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");
|
tracing::error!(error = %e, "failed to load secret schema list for web");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
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()
|
if let Some(current) = type_filter.as_ref()
|
||||||
&& !current.is_empty()
|
&& !current.is_empty()
|
||||||
&& !type_options.iter().any(|t| t == current)
|
&& !type_options.iter().any(|t| t == current)
|
||||||
@@ -309,6 +394,7 @@ pub(super) async fn entries_page(
|
|||||||
folder: Option<&str>,
|
folder: Option<&str>,
|
||||||
entry_type: Option<&str>,
|
entry_type: Option<&str>,
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
|
metadata_query: Option<&str>,
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut pairs: Vec<String> = Vec::new();
|
let mut pairs: Vec<String> = Vec::new();
|
||||||
@@ -327,6 +413,11 @@ pub(super) async fn entries_page(
|
|||||||
{
|
{
|
||||||
pairs.push(format!("name={}", urlencoding::encode(n)));
|
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 {
|
if let Some(p) = page {
|
||||||
pairs.push(format!("page={}", p));
|
pairs.push(format!("page={}", p));
|
||||||
}
|
}
|
||||||
@@ -346,6 +437,7 @@ pub(super) async fn entries_page(
|
|||||||
None,
|
None,
|
||||||
type_filter.as_deref(),
|
type_filter.as_deref(),
|
||||||
name_filter.as_deref(),
|
name_filter.as_deref(),
|
||||||
|
metadata_query_filter.as_deref(),
|
||||||
Some(1),
|
Some(1),
|
||||||
),
|
),
|
||||||
active: folder_filter.is_none(),
|
active: folder_filter.is_none(),
|
||||||
@@ -357,6 +449,7 @@ pub(super) async fn entries_page(
|
|||||||
Some(&name),
|
Some(&name),
|
||||||
type_filter.as_deref(),
|
type_filter.as_deref(),
|
||||||
name_filter.as_deref(),
|
name_filter.as_deref(),
|
||||||
|
metadata_query_filter.as_deref(),
|
||||||
Some(1),
|
Some(1),
|
||||||
),
|
),
|
||||||
active: folder_filter.as_deref() == Some(name.as_str()),
|
active: folder_filter.as_deref() == Some(name.as_str()),
|
||||||
@@ -368,6 +461,7 @@ pub(super) async fn entries_page(
|
|||||||
let entries = rows
|
let entries = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
|
||||||
let secrets: Vec<SecretSummaryView> = secret_schemas
|
let secrets: Vec<SecretSummaryView> = secret_schemas
|
||||||
.get(&e.id)
|
.get(&e.id)
|
||||||
.map(|fields| {
|
.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 secrets_json = serde_json::to_string(&secrets).unwrap_or_else(|_| "[]".to_string());
|
||||||
let metadata_json =
|
let metadata_json =
|
||||||
serde_json::to_string(&e.metadata).unwrap_or_else(|_| "{}".to_string());
|
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 {
|
EntryListItemView {
|
||||||
id: e.id.to_string(),
|
id: e.id.to_string(),
|
||||||
folder: e.folder,
|
folder: e.folder,
|
||||||
@@ -394,6 +491,9 @@ pub(super) async fn entries_page(
|
|||||||
metadata_json,
|
metadata_json,
|
||||||
secrets,
|
secrets,
|
||||||
secrets_json,
|
secrets_json,
|
||||||
|
parents,
|
||||||
|
children,
|
||||||
|
parents_json,
|
||||||
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
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(),
|
.unwrap_or_default(),
|
||||||
filter_folder: folder_filter.unwrap_or_default(),
|
filter_folder: folder_filter.unwrap_or_default(),
|
||||||
filter_name: name_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(),
|
filter_type: type_filter.unwrap_or_default(),
|
||||||
current_page,
|
current_page,
|
||||||
total_pages,
|
total_pages,
|
||||||
@@ -424,6 +525,56 @@ pub(super) async fn entries_page(
|
|||||||
render_template(tmpl)
|
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) ───────────────────────
|
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -435,6 +586,7 @@ pub(super) struct EntryPatchBody {
|
|||||||
notes: String,
|
notes: String,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
metadata: serde_json::Value,
|
metadata: serde_json::Value,
|
||||||
|
parent_ids: Option<Vec<Uuid>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn api_entry_patch(
|
pub(super) async fn api_entry_patch(
|
||||||
@@ -496,9 +648,70 @@ pub(super) async fn api_entry_patch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
.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 })))
|
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(
|
pub(super) async fn api_entry_delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -517,6 +730,51 @@ pub(super) async fn api_entry_delete(
|
|||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"ok": true,
|
"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("/auth/logout", post(auth::auth_logout))
|
||||||
.route("/dashboard", get(account::dashboard))
|
.route("/dashboard", get(account::dashboard))
|
||||||
.route("/entries", get(entries::entries_page))
|
.route("/entries", get(entries::entries_page))
|
||||||
|
.route("/trash", get(entries::trash_page))
|
||||||
.route("/audit", get(audit::audit_page))
|
.route("/audit", get(audit::audit_page))
|
||||||
.route("/account/bind/google", get(auth::account_bind_google))
|
.route("/account/bind/google", get(auth::account_bind_google))
|
||||||
.route("/account/unbind/{provider}", post(auth::account_unbind))
|
.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-setup", post(account::api_key_setup))
|
||||||
.route("/api/key-change", post(account::api_key_change))
|
.route("/api/key-change", post(account::api_key_change))
|
||||||
.route("/api/apikey", get(account::api_apikey_get))
|
.route("/api/apikey", get(account::api_apikey_get))
|
||||||
|
.route("/api/entries/options", get(entries::api_entry_options))
|
||||||
.route(
|
.route(
|
||||||
"/api/apikey/regenerate",
|
"/api/apikey/regenerate",
|
||||||
post(account::api_apikey_regenerate),
|
post(account::api_apikey_regenerate),
|
||||||
@@ -208,6 +210,11 @@ pub fn web_router() -> Router<AppState> {
|
|||||||
"/api/entries/{id}",
|
"/api/entries/{id}",
|
||||||
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
|
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(
|
.route(
|
||||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||||
axum::routing::delete(entries::api_entry_secret_unlink),
|
axum::routing::delete(entries::api_entry_secret_unlink),
|
||||||
|
|||||||
@@ -13,77 +13,87 @@
|
|||||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
--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; }
|
.layout { display: flex; min-height: 100vh; }
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||||
}
|
}
|
||||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||||
.sidebar-logo span { color: var(--accent); }
|
.sidebar-menu { display: grid; gap: 6px; }
|
||||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
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 {
|
.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; }
|
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
.topbar {
|
.topbar {
|
||||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
background: transparent; border-bottom: none; padding: 0 24px;
|
||||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||||
}
|
}
|
||||||
.topbar-spacer { flex: 1; }
|
.topbar-spacer { flex: 1; }
|
||||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
.nav-user { font-size: 14px; color: #8b949e; }
|
||||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||||
.btn-sign-out {
|
.btn-sign-out {
|
||||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
|
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-sign-out:hover { background: var(--surface2); }
|
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
.main { padding: 32px 24px 40px; flex: 1; }
|
.main { padding: 16px 16px 24px; flex: 1; }
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||||
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
|
padding: 20px; width: 100%; }
|
||||||
.card-title-row {
|
.card-title-row {
|
||||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
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 {
|
.card-title-count {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--bg);
|
background: #0d1117;
|
||||||
color: var(--text-muted);
|
color: #8b949e;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
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; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
th, td { text-align: left; vertical-align: top; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
|
||||||
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
|
th { color: #8b949e; font-size: 12px; font-weight: 600; }
|
||||||
td { font-size: 13px; }
|
td { font-size: 13px; color: #c9d1d9; }
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
.detail {
|
.col-detail { min-width: 260px; max-width: 460px; }
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
.detail-scroll {
|
||||||
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
|
height: calc(1.5em * 3 + 20px);
|
||||||
max-width: 460px;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.layout { flex-direction: column; }
|
.layout { flex-direction: column; }
|
||||||
.sidebar {
|
.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;
|
padding: 16px; gap: 14px;
|
||||||
}
|
}
|
||||||
.sidebar-menu { flex-direction: row; }
|
.sidebar-menu { flex-direction: row; }
|
||||||
@@ -93,42 +103,43 @@
|
|||||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||||
table, thead, tbody, th, td, tr { display: block; }
|
table, thead, tbody, th, td, tr { display: block; }
|
||||||
thead { display: none; }
|
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 { border-top: none; padding: 6px 0; }
|
||||||
td::before {
|
td::before {
|
||||||
display: block; color: var(--text-muted); font-size: 11px;
|
display: block; color: #8b949e; font-size: 11px;
|
||||||
margin-bottom: 4px; text-transform: uppercase;
|
margin-bottom: 4px; text-transform: uppercase;
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
}
|
}
|
||||||
.detail { max-width: none; }
|
.detail { max-width: none; }
|
||||||
}
|
}
|
||||||
.pagination {
|
.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;
|
justify-content: center; padding: 12px 0;
|
||||||
}
|
}
|
||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: var(--surface); color: var(--text); text-decoration: none;
|
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||||
font-size: 13px; cursor: pointer;
|
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 {
|
.page-btn-disabled {
|
||||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
background: #161b22; color: #6e7681; font-size: 13px;
|
||||||
opacity: 0.5; cursor: not-allowed;
|
opacity: 0.5; cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.page-info {
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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">
|
<nav class="sidebar-menu">
|
||||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||||
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</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>
|
<a href="/audit" class="sidebar-link active" data-i18n="navAudit">审计</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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-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-action mono" data-label="动作">{{ entry.action }}</td>
|
||||||
<td class="col-target mono" data-label="目标">{{ entry.target }}</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -198,7 +209,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/i18n.js"></script>
|
<script src="/static/i18n.js?v={{ version }}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
I18N_PAGE = {
|
I18N_PAGE = {
|
||||||
@@ -228,6 +239,7 @@
|
|||||||
el.title = raw + ' (UTC)';
|
el.title = raw + ' (UTC)';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
applyLang();
|
applyLang();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,110 +18,108 @@
|
|||||||
|
|
||||||
.layout { display: flex; min-height: 100vh; }
|
.layout { display: flex; min-height: 100vh; }
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||||
}
|
}
|
||||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||||
.sidebar-logo span { color: var(--accent); }
|
.sidebar-menu { display: grid; gap: 6px; }
|
||||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
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);
|
|
||||||
}
|
}
|
||||||
|
.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; }
|
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
.topbar {
|
.topbar {
|
||||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
background: transparent; border-bottom: none; padding: 0 24px;
|
||||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||||
}
|
}
|
||||||
.topbar-spacer { flex: 1; }
|
.topbar-spacer { flex: 1; }
|
||||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
.nav-user { font-size: 14px; color: #8b949e; }
|
||||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
.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);
|
.btn-sign-out {
|
||||||
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
|
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
.btn-sign-out:hover { background: var(--surface2); }
|
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 content column */
|
||||||
.main { display: flex; flex-direction: column; align-items: center;
|
.main { padding: 16px 16px 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
padding: 24px 20px 8px; min-height: 0; }
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
margin-top: auto;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4px 20px 12px;
|
padding: 12px 0;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #9da7b3;
|
color: var(--text-muted);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||||
padding: 24px; width: 100%; max-width: 980px; }
|
padding: 20px; width: 100%; }
|
||||||
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
|
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
|
||||||
/* Form */
|
/* Form */
|
||||||
.field { margin-bottom: 12px; }
|
.field { margin-bottom: 12px; }
|
||||||
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
.field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
|
||||||
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
.field input { width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||||
color: var(--text); padding: 9px 12px; border-radius: 6px;
|
color: #c9d1d9; padding: 9px 12px; border-radius: 10px;
|
||||||
font-size: 13px; outline: none; }
|
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 { position: relative; }
|
||||||
.pw-field > input { padding-right: 42px; }
|
.pw-field > input { padding-right: 42px; }
|
||||||
.pw-toggle {
|
.pw-toggle {
|
||||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 32px; height: 32px; border: none; border-radius: 6px;
|
width: 32px; height: 32px; border: none; border-radius: 8px;
|
||||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
background: transparent; color: #8b949e; cursor: pointer;
|
||||||
}
|
}
|
||||||
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
|
.pw-toggle:hover { color: #c9d1d9; background: rgba(240,246,252,0.06); }
|
||||||
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.pw-toggle:focus-visible { outline: 2px solid rgba(56,139,253,0.5); outline-offset: 2px; }
|
||||||
.pw-icon svg { display: block; }
|
.pw-icon svg { display: block; }
|
||||||
.pw-icon.hidden { display: none; }
|
.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 */
|
/* Buttons */
|
||||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
|
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
|
||||||
justify-content: center; padding: 10px 20px; border-radius: 7px;
|
justify-content: center; padding: 10px 20px; border-radius: 10px;
|
||||||
border: none; background: var(--accent); color: #0d1117;
|
border: none; background: #388bfd; color: #fff;
|
||||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
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-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
|
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px;
|
||||||
border-radius: 5px; border: 1px solid var(--border); background: none;
|
border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: #161b22;
|
||||||
color: var(--text-muted); font-size: 12px; cursor: pointer; }
|
color: #8b949e; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||||
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
|
.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;
|
.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);
|
padding: 11px 20px; border-radius: 10px; border: 1px solid #3fb950;
|
||||||
background: rgba(63,185,80,0.1); color: var(--success);
|
background: rgba(63,185,80,0.1); color: #3fb950;
|
||||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
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: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 format switcher */
|
||||||
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
|
.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);
|
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.08);
|
||||||
background: var(--surface2); color: var(--text-muted); cursor: pointer;
|
background: #161b22; color: #8b949e; cursor: pointer;
|
||||||
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
|
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:hover { color: #c9d1d9; border-color: rgba(56,139,253,0.45); transform: translateY(-1px); }
|
||||||
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
|
.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-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
|
||||||
/* Config box */
|
/* Config box */
|
||||||
.config-wrap { position: relative; margin-bottom: 14px; }
|
.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;
|
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||||
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
|
line-height: 1.7; color: #c9d1d9; overflow-x: auto; white-space: pre; }
|
||||||
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
|
.config-box.locked { color: #8b949e; filter: blur(3px); user-select: none;
|
||||||
pointer-events: none; }
|
pointer-events: none; }
|
||||||
.config-key { color: #79c0ff; }
|
.config-key { color: #79c0ff; }
|
||||||
.config-str { color: #a5d6ff; }
|
.config-str { color: #a5d6ff; }
|
||||||
.config-val { color: var(--accent); }
|
.config-val { color: #58a6ff; }
|
||||||
|
|
||||||
/* Divider */
|
/* 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 */
|
||||||
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
.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);
|
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
||||||
z-index: 100; align-items: center; justify-content: center; }
|
z-index: 100; align-items: center; justify-content: center; }
|
||||||
.modal-bd.open { display: flex; }
|
.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; }
|
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; }
|
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
|
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 10px; border: none;
|
||||||
background: var(--accent); color: #0d1117; font-size: 13px;
|
background: #388bfd; color: #fff; font-size: 13px;
|
||||||
font-weight: 600; cursor: pointer; }
|
font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||||
.btn-modal-ok:hover { background: var(--accent-hover); }
|
.btn-modal-ok:hover { background: #58a6ff; }
|
||||||
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
|
.btn-modal-cancel { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
|
background: #161b22; color: #c9d1d9; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||||
.btn-modal-cancel:hover { background: var(--surface2); }
|
.btn-modal-cancel:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout { flex-direction: column; }
|
.layout { flex-direction: column; }
|
||||||
.sidebar {
|
.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;
|
padding: 16px; gap: 14px;
|
||||||
}
|
}
|
||||||
.sidebar-menu { flex-direction: row; }
|
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||||
.sidebar-link { flex: 1; text-align: center; }
|
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
|
||||||
}
|
.main { padding: 20px 12px 28px; }
|
||||||
|
.card { padding: 16px; }
|
||||||
@media (max-width: 720px) {
|
|
||||||
.config-tabs { grid-template-columns: 1fr; }
|
|
||||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
||||||
.main { padding: 16px 12px 6px; }
|
|
||||||
.app-footer { padding: 4px 12px 10px; }
|
|
||||||
.card { padding: 18px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -171,11 +164,12 @@
|
|||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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">
|
<nav class="sidebar-menu">
|
||||||
<a href="/dashboard" class="sidebar-link active">MCP</a>
|
<a href="/dashboard" class="sidebar-link active" data-i18n="navMcp">MCP</a>
|
||||||
<a href="/entries" class="sidebar-link">条目</a>
|
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
|
||||||
<a href="/audit" class="sidebar-link">审计</a>
|
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
|
||||||
|
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -293,13 +287,11 @@
|
|||||||
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
|
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<footer class="app-footer">{{ version }}</footer>
|
<footer class="app-footer">{{ version }}</footer>
|
||||||
</div>
|
</div><!-- /main -->
|
||||||
</div>
|
</div><!-- /content-shell -->
|
||||||
</div>
|
</div><!-- /layout -->
|
||||||
|
|
||||||
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
|
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
|
||||||
<div class="modal-bd" id="change-modal">
|
<div class="modal-bd" id="change-modal">
|
||||||
@@ -351,6 +343,7 @@
|
|||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
|
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
|
||||||
signOut: '退出',
|
signOut: '退出',
|
||||||
lockedTitle: '获取 MCP 配置',
|
lockedTitle: '获取 MCP 配置',
|
||||||
labelPassphrase: '加密密码',
|
labelPassphrase: '加密密码',
|
||||||
@@ -388,6 +381,7 @@ const T = {
|
|||||||
ariaHidePw: '隐藏密码',
|
ariaHidePw: '隐藏密码',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
|
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||||
signOut: '登出',
|
signOut: '登出',
|
||||||
lockedTitle: '取得 MCP 設定',
|
lockedTitle: '取得 MCP 設定',
|
||||||
labelPassphrase: '加密密碼',
|
labelPassphrase: '加密密碼',
|
||||||
@@ -425,6 +419,7 @@ const T = {
|
|||||||
ariaHidePw: '隱藏密碼',
|
ariaHidePw: '隱藏密碼',
|
||||||
},
|
},
|
||||||
'en': {
|
'en': {
|
||||||
|
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
||||||
signOut: 'Sign out',
|
signOut: 'Sign out',
|
||||||
lockedTitle: 'Get MCP Config',
|
lockedTitle: 'Get MCP Config',
|
||||||
labelPassphrase: 'Encryption password',
|
labelPassphrase: 'Encryption password',
|
||||||
|
|||||||
@@ -13,45 +13,44 @@
|
|||||||
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
|
||||||
--accent: #58a6ff; --accent-hover: #79b8ff;
|
--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; }
|
.layout { display: flex; min-height: 100vh; }
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
|
||||||
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
|
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
|
||||||
}
|
}
|
||||||
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
|
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
|
||||||
color: var(--text); text-decoration: none; padding: 0 10px; }
|
color: #fff; text-decoration: none; padding: 0 10px; }
|
||||||
.sidebar-logo span { color: var(--accent); }
|
.sidebar-menu { display: grid; gap: 6px; }
|
||||||
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
|
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
|
||||||
border: 1px solid transparent; font-size: 13px; font-weight: 500;
|
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 {
|
.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; }
|
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
.topbar {
|
.topbar {
|
||||||
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
|
background: transparent; border-bottom: none; padding: 0 24px;
|
||||||
display: flex; align-items: center; gap: 12px; min-height: 52px;
|
display: flex; align-items: center; gap: 12px; min-height: 44px;
|
||||||
}
|
}
|
||||||
.topbar-spacer { flex: 1; }
|
.topbar-spacer { flex: 1; }
|
||||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
.nav-user { font-size: 14px; color: #8b949e; }
|
||||||
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
|
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
|
||||||
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
|
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
|
||||||
font-size: 12px; cursor: pointer; border-radius: 4px; }
|
font-size: 12px; cursor: pointer; border-radius: 6px; }
|
||||||
.lang-btn.active { background: var(--border); color: var(--text); }
|
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
|
||||||
.btn-sign-out {
|
.btn-sign-out {
|
||||||
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
|
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-sign-out:hover { background: var(--surface2); }
|
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
.main { padding: 32px 24px 40px; flex: 1; }
|
.main { padding: 16px 16px 24px; flex: 1; }
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||||
padding: 24px; width: 100%; max-width: 1480px; margin: 0 auto; }
|
padding: 20px; width: 100%; }
|
||||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; color: #fff; }
|
||||||
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
|
||||||
.folder-tabs {
|
.folder-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -60,40 +59,40 @@
|
|||||||
}
|
}
|
||||||
.folder-tab {
|
.folder-tab {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-muted);
|
color: #8b949e;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
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 {
|
.folder-tab.active {
|
||||||
background: rgba(88,166,255,0.12);
|
background: rgba(56,139,253,0.14);
|
||||||
border-color: rgba(88,166,255,0.35);
|
border-color: rgba(56,139,253,0.3);
|
||||||
color: var(--text);
|
color: #fff;
|
||||||
}
|
}
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
||||||
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
|
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
|
.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 {
|
.filter-field input {
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
color: #c9d1d9; padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
outline: none; width: 100%;
|
outline: none; width: 100%;
|
||||||
}
|
}
|
||||||
.filter-field select {
|
.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-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-repeat: no-repeat;
|
||||||
background-position: right 14px center;
|
background-position: right 14px center;
|
||||||
background-size: 12px 12px;
|
background-size: 12px 12px;
|
||||||
border: 1px solid var(--border); border-radius: 6px;
|
border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||||
color: var(--text);
|
color: #c9d1d9;
|
||||||
padding: 8px 2.8rem 8px 10px;
|
padding: 8px 2.8rem 8px 10px;
|
||||||
font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
outline: none; width: 100%;
|
outline: none; width: 100%;
|
||||||
@@ -103,24 +102,24 @@
|
|||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
}
|
}
|
||||||
.filter-field input:focus,
|
.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; }
|
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
.btn-filter {
|
.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;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-filter:hover { background: var(--accent-hover); }
|
.btn-filter:hover { background: #58a6ff; }
|
||||||
.btn-clear {
|
.btn-clear {
|
||||||
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
|
padding: 8px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: transparent;
|
||||||
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
|
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-clear:hover { background: var(--surface2); color: var(--text); }
|
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
background: var(--bg);
|
background: #0d1117;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -128,20 +127,20 @@
|
|||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
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 {
|
th {
|
||||||
color: var(--text-muted);
|
color: #8b949e;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--surface);
|
background: #111827;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
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); }
|
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
|
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
|
||||||
@@ -163,13 +162,13 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--bg);
|
background: #0d1117;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.detail {
|
.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;
|
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
|
||||||
max-width: 360px; max-height: 120px; overflow: auto;
|
max-width: 360px; max-height: 120px; overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -180,11 +179,11 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--surface2);
|
background: #161b22;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -196,8 +195,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.secret-type {
|
.secret-type {
|
||||||
color: var(--text-muted);
|
color: #8b949e;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid rgba(240,246,252,0.08);
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
.btn-unlink-secret {
|
.btn-unlink-secret {
|
||||||
@@ -224,17 +223,17 @@
|
|||||||
.secret-name-input {
|
.secret-name-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--bg);
|
background: #0d1117;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
color: var(--text);
|
color: #c9d1d9;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.secret-name-input:focus {
|
.secret-name-input:focus {
|
||||||
border-color: var(--accent);
|
border-color: rgba(56,139,253,0.5);
|
||||||
}
|
}
|
||||||
.secret-name-input.invalid {
|
.secret-name-input.invalid {
|
||||||
border-color: #f85149;
|
border-color: #f85149;
|
||||||
@@ -245,10 +244,10 @@
|
|||||||
.secret-type-select {
|
.secret-type-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface2);
|
background: #161b22;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
color: var(--text);
|
color: #c9d1d9;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
@@ -256,7 +255,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.secret-type-select:focus {
|
.secret-type-select:focus {
|
||||||
border-color: var(--accent);
|
border-color: rgba(56,139,253,0.5);
|
||||||
}
|
}
|
||||||
.secret-type-select.invalid {
|
.secret-type-select.invalid {
|
||||||
border-color: #f85149;
|
border-color: #f85149;
|
||||||
@@ -274,13 +273,13 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: var(--text-muted);
|
color: #8b949e;
|
||||||
}
|
}
|
||||||
.secret-name-status:empty {
|
.secret-name-status:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.secret-name-status.checking {
|
.secret-name-status.checking {
|
||||||
color: var(--accent);
|
color: #58a6ff;
|
||||||
}
|
}
|
||||||
.secret-name-status.error {
|
.secret-name-status.error {
|
||||||
color: #f85149;
|
color: #f85149;
|
||||||
@@ -289,11 +288,11 @@
|
|||||||
color: #3fb950;
|
color: #3fb950;
|
||||||
}
|
}
|
||||||
.btn-row {
|
.btn-row {
|
||||||
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
|
padding: 8px 12px; border-radius: 10px; font-size: 13px; cursor: pointer;
|
||||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
|
||||||
font-family: inherit;
|
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; }
|
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
|
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-overlay[hidden] { display: none !important; }
|
||||||
.modal {
|
.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;
|
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
|
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
|
||||||
}
|
}
|
||||||
.modal.modal-wide {
|
.modal.modal-wide {
|
||||||
max-width: 800px;
|
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 { 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 {
|
.modal-field input, .modal-field textarea {
|
||||||
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
|
||||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
color: #c9d1d9; padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.modal-field textarea { resize: vertical; }
|
.modal-field textarea { resize: vertical; }
|
||||||
@@ -323,22 +322,23 @@
|
|||||||
}
|
}
|
||||||
.modal-field textarea.metadata-edit { min-height: 140px; }
|
.modal-field textarea.metadata-edit { min-height: 140px; }
|
||||||
.modal-readonly-value {
|
.modal-readonly-value {
|
||||||
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;
|
||||||
color: var(--text-muted); padding: 8px 10px; font-size: 13px;
|
color: #8b949e; padding: 8px 10px; font-size: 13px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.modal-secrets .secret-list { display: flex; flex-direction: column; gap: 10px; }
|
.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 { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
|
||||||
.modal-error.visible { display: block; }
|
.modal-error.visible { display: block; }
|
||||||
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
.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 { 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.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
|
.btn-modal:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
|
||||||
.btn-modal.primary:hover { background: var(--accent-hover); }
|
.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; }
|
.btn-modal.danger { border-color: #f85149; color: #f85149; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout { flex-direction: column; }
|
.layout { flex-direction: column; }
|
||||||
.sidebar {
|
.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;
|
padding: 16px; gap: 14px;
|
||||||
}
|
}
|
||||||
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
|
||||||
@@ -349,10 +349,10 @@
|
|||||||
.table-wrap { max-height: none; border: none; background: transparent; }
|
.table-wrap { max-height: none; border: none; background: transparent; }
|
||||||
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
|
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
|
||||||
thead { display: none; }
|
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 { border-top: none; padding: 6px 0; max-width: none; }
|
||||||
td::before {
|
td::before {
|
||||||
display: block; color: var(--text-muted); font-size: 11px;
|
display: block; color: #8b949e; font-size: 11px;
|
||||||
margin-bottom: 4px; text-transform: uppercase;
|
margin-bottom: 4px; text-transform: uppercase;
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
}
|
}
|
||||||
@@ -362,26 +362,26 @@
|
|||||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
.detail, .notes-scroll, .secret-list { max-width: none; }
|
||||||
}
|
}
|
||||||
.pagination {
|
.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;
|
justify-content: center; padding: 12px 0;
|
||||||
}
|
}
|
||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: var(--surface); color: var(--text); text-decoration: none;
|
background: #161b22; color: #c9d1d9; text-decoration: none;
|
||||||
font-size: 13px; cursor: pointer;
|
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 {
|
.page-btn-disabled {
|
||||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
|
||||||
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
background: #161b22; color: #6e7681; font-size: 13px;
|
||||||
opacity: 0.5; cursor: not-allowed;
|
opacity: 0.5; cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.page-info {
|
.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 {
|
.view-secret-row {
|
||||||
display: flex; flex-direction: column; gap: 4px; padding: 8px 0;
|
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-row:last-child { border-bottom: none; }
|
||||||
.view-secret-header {
|
.view-secret-header {
|
||||||
@@ -389,59 +389,60 @@
|
|||||||
}
|
}
|
||||||
.view-secret-name {
|
.view-secret-name {
|
||||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||||
color: var(--text); font-weight: 600;
|
color: #c9d1d9; font-weight: 600;
|
||||||
}
|
}
|
||||||
.view-secret-type {
|
.view-secret-type {
|
||||||
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||||
color: var(--text-muted); background: var(--surface2);
|
color: #8b949e; background: #161b22;
|
||||||
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
|
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-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||||
.view-secret-value-wrap { position: relative; }
|
.view-secret-value-wrap { position: relative; }
|
||||||
.view-secret-value {
|
.view-secret-value {
|
||||||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
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;
|
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); }
|
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;
|
padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer;
|
||||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
|
||||||
font-family: inherit;
|
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 {
|
.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;
|
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-wrap { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||||
.view-secret-name-input {
|
.view-secret-name-input {
|
||||||
width: 180px; max-width: 100%; background: var(--bg); border: 1px solid var(--border);
|
width: 180px; max-width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
|
||||||
border-radius: 4px; color: var(--text); padding: 2px 8px; font-size: 12px;
|
border-radius: 8px; color: #c9d1d9; padding: 2px 8px; font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace; outline: none;
|
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 {
|
.view-secret-type-select {
|
||||||
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
|
background: #161b22; border: 1px solid rgba(240,246,252,0.08); border-radius: 8px;
|
||||||
color: var(--text); padding: 2px 6px; font-size: 11px;
|
color: #c9d1d9; padding: 2px 6px; font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
|
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.view-secret-type-select:focus { border-color: var(--accent); }
|
.view-secret-type-select:focus { border-color: rgba(56,139,253,0.5); }
|
||||||
.btn-view-edit { color: var(--accent); }
|
.btn-view-edit { color: #58a6ff; }
|
||||||
.btn-view-save { color: #3fb950; }
|
.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; }
|
.btn-view-unlink { color: #f85149; font-size: 14px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<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">
|
<nav class="sidebar-menu">
|
||||||
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
|
||||||
<a href="/entries" class="sidebar-link active" data-i18n="navEntries">条目</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>
|
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -480,6 +481,10 @@
|
|||||||
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
|
<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">
|
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
|
||||||
</div>
|
</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">
|
<div class="filter-field">
|
||||||
<label for="filter-type" data-i18n="filterTypeLabel">类型</label>
|
<label for="filter-type" data-i18n="filterTypeLabel">类型</label>
|
||||||
<select id="filter-type" name="type" onchange="this.form.requestSubmit();">
|
<select id="filter-type" name="type" onchange="this.form.requestSubmit();">
|
||||||
@@ -506,17 +511,34 @@
|
|||||||
<th data-i18n="colType">类型</th>
|
<th data-i18n="colType">类型</th>
|
||||||
<th data-i18n="colNotes">备注</th>
|
<th data-i18n="colNotes">备注</th>
|
||||||
<th data-i18n="colTags">标签</th>
|
<th data-i18n="colTags">标签</th>
|
||||||
|
<th data-i18n="colRelations">关联</th>
|
||||||
<th data-i18n="colSecrets">密文</th>
|
<th data-i18n="colSecrets">密文</th>
|
||||||
<th data-i18n="colActions">操作</th>
|
<th data-i18n="colActions">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-updated-at="{{ entry.updated_at_iso }}">
|
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-entry-parents="{{ entry.parents_json }}" data-updated-at="{{ entry.updated_at_iso }}">
|
||||||
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
|
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
|
||||||
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
|
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
|
||||||
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
|
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
|
||||||
<td class="col-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
|
<td class="col-tags mono cell-tags-val" data-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="密文">
|
<td class="col-secrets" data-label="密文">
|
||||||
<div class="secret-list">
|
<div class="secret-list">
|
||||||
{% for s in entry.secrets %}
|
{% for s in entry.secrets %}
|
||||||
@@ -543,13 +565,13 @@
|
|||||||
{% if total_count > 0 %}
|
{% if total_count > 0 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if current_page > 1 %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||||
{% if current_page < total_pages %}
|
{% 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 %}
|
{% else %}
|
||||||
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||||
{% endif %}
|
{% 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 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 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-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">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-modal" id="edit-cancel" data-i18n="modalCancel">取消</button>
|
<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>
|
<button type="button" class="btn-modal primary" id="edit-save" data-i18n="modalSave">保存</button>
|
||||||
@@ -579,31 +607,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="view-overlay" class="modal-overlay" hidden>
|
||||||
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="view-title">
|
<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 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 id="view-body"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
|
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
|
||||||
<script>
|
<script>
|
||||||
var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-options').textContent);
|
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 = {
|
I18N_PAGE = {
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
pageTitle: 'Secrets — 条目',
|
pageTitle: 'Secrets — 条目',
|
||||||
|
navTrash: '回收站',
|
||||||
entriesTitle: '我的条目',
|
entriesTitle: '我的条目',
|
||||||
allTab: '全部',
|
allTab: '全部',
|
||||||
filterNameLabel: '名称',
|
filterNameLabel: '名称',
|
||||||
filterNamePlaceholder: '输入关键字',
|
filterNamePlaceholder: '输入关键字',
|
||||||
|
filterMetadataLabel: '元数据值',
|
||||||
|
filterMetadataPlaceholder: '搜索元数据值',
|
||||||
filterTypeLabel: '类型',
|
filterTypeLabel: '类型',
|
||||||
filterTypeAll: '全部',
|
filterTypeAll: '全部',
|
||||||
filterSubmit: '筛选',
|
filterSubmit: '筛选',
|
||||||
@@ -624,8 +643,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colType: '类型',
|
colType: '类型',
|
||||||
colNotes: '备注',
|
colNotes: '备注',
|
||||||
colTags: '标签',
|
colTags: '标签',
|
||||||
|
colRelations: '关联',
|
||||||
colSecrets: '密文',
|
colSecrets: '密文',
|
||||||
colActions: '操作',
|
colActions: '操作',
|
||||||
|
relationParentBadge: '上级',
|
||||||
|
relationChildBadge: '下级',
|
||||||
rowEdit: '编辑条目',
|
rowEdit: '编辑条目',
|
||||||
rowDelete: '删除',
|
rowDelete: '删除',
|
||||||
modalTitle: '编辑条目信息',
|
modalTitle: '编辑条目信息',
|
||||||
@@ -636,16 +658,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
modalTags: '标签(逗号分隔)',
|
modalTags: '标签(逗号分隔)',
|
||||||
modalUpdated: '更新',
|
modalUpdated: '更新',
|
||||||
modalMetadata: '元数据(JSON 对象)',
|
modalMetadata: '元数据(JSON 对象)',
|
||||||
|
modalParents: '上级条目',
|
||||||
modalSecrets: '密文',
|
modalSecrets: '密文',
|
||||||
modalCancel: '取消',
|
modalCancel: '取消',
|
||||||
modalSave: '保存',
|
modalSave: '保存',
|
||||||
deleteTitle: '确认删除',
|
|
||||||
deleteConfirm: '删除',
|
|
||||||
deleteMessage: '确定删除条目「{name}」?此操作不可撤销。',
|
|
||||||
mobileLabelName: '名称',
|
mobileLabelName: '名称',
|
||||||
mobileLabelType: '类型',
|
mobileLabelType: '类型',
|
||||||
mobileLabelNotes: '备注',
|
mobileLabelNotes: '备注',
|
||||||
mobileLabelTags: '标签',
|
mobileLabelTags: '标签',
|
||||||
|
mobileLabelRelations: '关联',
|
||||||
mobileLabelSecrets: '密文',
|
mobileLabelSecrets: '密文',
|
||||||
mobileLabelActions: '操作',
|
mobileLabelActions: '操作',
|
||||||
errInvalidJson: '元数据不是合法 JSON',
|
errInvalidJson: '元数据不是合法 JSON',
|
||||||
@@ -679,13 +700,19 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewSaveChanges: '保存更改',
|
viewSaveChanges: '保存更改',
|
||||||
viewChangesSaved: '已保存',
|
viewChangesSaved: '已保存',
|
||||||
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
||||||
|
parentSearchPlaceholder: '按名称搜索条目',
|
||||||
|
parentSearchEmpty: '没有匹配的条目',
|
||||||
|
removeParent: '移除上级',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
pageTitle: 'Secrets — 條目',
|
pageTitle: 'Secrets — 條目',
|
||||||
|
navTrash: '回收站',
|
||||||
entriesTitle: '我的條目',
|
entriesTitle: '我的條目',
|
||||||
allTab: '全部',
|
allTab: '全部',
|
||||||
filterNameLabel: '名稱',
|
filterNameLabel: '名稱',
|
||||||
filterNamePlaceholder: '輸入關鍵字',
|
filterNamePlaceholder: '輸入關鍵字',
|
||||||
|
filterMetadataLabel: '中繼資料值',
|
||||||
|
filterMetadataPlaceholder: '搜尋中繼資料值',
|
||||||
filterTypeLabel: '類型',
|
filterTypeLabel: '類型',
|
||||||
filterTypeAll: '全部',
|
filterTypeAll: '全部',
|
||||||
filterSubmit: '篩選',
|
filterSubmit: '篩選',
|
||||||
@@ -695,8 +722,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colType: '類型',
|
colType: '類型',
|
||||||
colNotes: '備註',
|
colNotes: '備註',
|
||||||
colTags: '標籤',
|
colTags: '標籤',
|
||||||
|
colRelations: '關聯',
|
||||||
colSecrets: '密文',
|
colSecrets: '密文',
|
||||||
colActions: '操作',
|
colActions: '操作',
|
||||||
|
relationParentBadge: '上級',
|
||||||
|
relationChildBadge: '下級',
|
||||||
rowEdit: '編輯條目',
|
rowEdit: '編輯條目',
|
||||||
rowDelete: '刪除',
|
rowDelete: '刪除',
|
||||||
modalTitle: '編輯條目資訊',
|
modalTitle: '編輯條目資訊',
|
||||||
@@ -707,16 +737,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
modalTags: '標籤(逗號分隔)',
|
modalTags: '標籤(逗號分隔)',
|
||||||
modalUpdated: '更新時間',
|
modalUpdated: '更新時間',
|
||||||
modalMetadata: '中繼資料(JSON 物件)',
|
modalMetadata: '中繼資料(JSON 物件)',
|
||||||
|
modalParents: '上級條目',
|
||||||
modalSecrets: '密文',
|
modalSecrets: '密文',
|
||||||
modalCancel: '取消',
|
modalCancel: '取消',
|
||||||
modalSave: '儲存',
|
modalSave: '儲存',
|
||||||
deleteTitle: '確認刪除',
|
|
||||||
deleteConfirm: '刪除',
|
|
||||||
deleteMessage: '確定刪除條目「{name}」?此操作不可復原。',
|
|
||||||
mobileLabelName: '名稱',
|
mobileLabelName: '名稱',
|
||||||
mobileLabelType: '類型',
|
mobileLabelType: '類型',
|
||||||
mobileLabelNotes: '備註',
|
mobileLabelNotes: '備註',
|
||||||
mobileLabelTags: '標籤',
|
mobileLabelTags: '標籤',
|
||||||
|
mobileLabelRelations: '關聯',
|
||||||
mobileLabelSecrets: '密文',
|
mobileLabelSecrets: '密文',
|
||||||
mobileLabelActions: '操作',
|
mobileLabelActions: '操作',
|
||||||
errInvalidJson: '中繼資料不是合法 JSON',
|
errInvalidJson: '中繼資料不是合法 JSON',
|
||||||
@@ -750,13 +779,19 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewSaveChanges: '儲存變更',
|
viewSaveChanges: '儲存變更',
|
||||||
viewChangesSaved: '已儲存',
|
viewChangesSaved: '已儲存',
|
||||||
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
||||||
|
parentSearchPlaceholder: '依名稱搜尋條目',
|
||||||
|
parentSearchEmpty: '沒有符合的條目',
|
||||||
|
removeParent: '移除上級',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
pageTitle: 'Secrets — Entries',
|
pageTitle: 'Secrets — Entries',
|
||||||
|
navTrash: 'Trash',
|
||||||
entriesTitle: 'My entries',
|
entriesTitle: 'My entries',
|
||||||
allTab: 'All',
|
allTab: 'All',
|
||||||
filterNameLabel: 'Name',
|
filterNameLabel: 'Name',
|
||||||
filterNamePlaceholder: 'Enter keywords',
|
filterNamePlaceholder: 'Enter keywords',
|
||||||
|
filterMetadataLabel: 'Metadata value',
|
||||||
|
filterMetadataPlaceholder: 'Search metadata values',
|
||||||
filterTypeLabel: 'Type',
|
filterTypeLabel: 'Type',
|
||||||
filterTypeAll: 'All',
|
filterTypeAll: 'All',
|
||||||
filterSubmit: 'Filter',
|
filterSubmit: 'Filter',
|
||||||
@@ -766,8 +801,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colType: 'Type',
|
colType: 'Type',
|
||||||
colNotes: 'Notes',
|
colNotes: 'Notes',
|
||||||
colTags: 'Tags',
|
colTags: 'Tags',
|
||||||
|
colRelations: 'Relations',
|
||||||
colSecrets: 'Secrets',
|
colSecrets: 'Secrets',
|
||||||
colActions: 'Actions',
|
colActions: 'Actions',
|
||||||
|
relationParentBadge: 'Parent',
|
||||||
|
relationChildBadge: 'Child',
|
||||||
rowEdit: 'Edit entry',
|
rowEdit: 'Edit entry',
|
||||||
rowDelete: 'Delete',
|
rowDelete: 'Delete',
|
||||||
modalTitle: 'Edit entry details',
|
modalTitle: 'Edit entry details',
|
||||||
@@ -778,16 +816,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
modalTags: 'Tags (comma separated)',
|
modalTags: 'Tags (comma separated)',
|
||||||
modalUpdated: 'Updated',
|
modalUpdated: 'Updated',
|
||||||
modalMetadata: 'Metadata (JSON object)',
|
modalMetadata: 'Metadata (JSON object)',
|
||||||
|
modalParents: 'Parent entries',
|
||||||
modalSecrets: 'Secrets',
|
modalSecrets: 'Secrets',
|
||||||
modalCancel: 'Cancel',
|
modalCancel: 'Cancel',
|
||||||
modalSave: 'Save',
|
modalSave: 'Save',
|
||||||
deleteTitle: 'Confirm Delete',
|
|
||||||
deleteConfirm: 'Delete',
|
|
||||||
deleteMessage: 'Are you sure you want to delete entry "{name}"? This action cannot be undone.',
|
|
||||||
mobileLabelName: 'Name',
|
mobileLabelName: 'Name',
|
||||||
mobileLabelType: 'Type',
|
mobileLabelType: 'Type',
|
||||||
mobileLabelNotes: 'Notes',
|
mobileLabelNotes: 'Notes',
|
||||||
mobileLabelTags: 'Tags',
|
mobileLabelTags: 'Tags',
|
||||||
|
mobileLabelRelations: 'Relations',
|
||||||
mobileLabelSecrets: 'Secrets',
|
mobileLabelSecrets: 'Secrets',
|
||||||
mobileLabelActions: 'Actions',
|
mobileLabelActions: 'Actions',
|
||||||
errInvalidJson: 'Metadata is not valid JSON',
|
errInvalidJson: 'Metadata is not valid JSON',
|
||||||
@@ -821,6 +858,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
viewSaveChanges: 'Save changes',
|
viewSaveChanges: 'Save changes',
|
||||||
viewChangesSaved: 'Saved',
|
viewChangesSaved: 'Saved',
|
||||||
viewUnlinkConfirm: 'Unlink secret "{name}"?',
|
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-type': 'mobileLabelType',
|
||||||
'.col-notes': 'mobileLabelNotes',
|
'.col-notes': 'mobileLabelNotes',
|
||||||
'.col-tags': 'mobileLabelTags',
|
'.col-tags': 'mobileLabelTags',
|
||||||
|
'.col-relations': 'mobileLabelRelations',
|
||||||
'.col-secrets': 'mobileLabelSecrets',
|
'.col-secrets': 'mobileLabelSecrets',
|
||||||
'.col-actions': 'mobileLabelActions'
|
'.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 editTags = document.getElementById('edit-tags');
|
||||||
var editMetadata = document.getElementById('edit-metadata');
|
var editMetadata = document.getElementById('edit-metadata');
|
||||||
var editUpdatedAt = document.getElementById('edit-updated-at');
|
var editUpdatedAt = document.getElementById('edit-updated-at');
|
||||||
var deleteOverlay = document.getElementById('delete-overlay');
|
var editParentList = document.getElementById('edit-parent-list');
|
||||||
var deleteError = document.getElementById('delete-error');
|
var editParentSearch = document.getElementById('edit-parent-search');
|
||||||
var deleteMessage = document.getElementById('delete-message');
|
var editParentResults = document.getElementById('edit-parent-results');
|
||||||
var currentEntryId = null;
|
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 ────────────────────────────────────────────────────
|
// ── View secrets modal ────────────────────────────────────────────────────
|
||||||
var viewOverlay = document.getElementById('view-overlay');
|
var viewOverlay = document.getElementById('view-overlay');
|
||||||
@@ -1264,6 +1392,14 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
editMetadata.value = md;
|
editMetadata.value = md;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
selectedParents = JSON.parse(tr.getAttribute('data-entry-parents') || '[]');
|
||||||
|
} catch (err) {
|
||||||
|
selectedParents = [];
|
||||||
|
}
|
||||||
|
editParentSearch.value = '';
|
||||||
|
editParentResults.innerHTML = '';
|
||||||
|
renderSelectedParents();
|
||||||
editOverlay.hidden = false;
|
editOverlay.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,37 +1409,31 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
showEditErr('');
|
showEditErr('');
|
||||||
editUpdatedAt.textContent = '';
|
editUpdatedAt.textContent = '';
|
||||||
editUpdatedAt.title = '';
|
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);
|
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
|
||||||
editOverlay.addEventListener('click', function (e) {
|
editOverlay.addEventListener('click', function (e) {
|
||||||
if (e.target === editOverlay) closeEdit();
|
if (e.target === editOverlay) closeEdit();
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
|
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
|
||||||
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
|
|
||||||
if (e.key === 'Escape' && !viewOverlay.hidden) closeView();
|
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) {
|
function refreshListAfterSave(entryId, body) {
|
||||||
|
if (body.parent_ids) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||||||
if (!tr) { window.location.reload(); return; }
|
if (!tr) { window.location.reload(); return; }
|
||||||
var nameCell = tr.querySelector('.cell-name');
|
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 () {
|
document.getElementById('edit-save').addEventListener('click', function () {
|
||||||
if (!currentEntryId) return;
|
if (!currentEntryId) return;
|
||||||
var meta;
|
var meta;
|
||||||
@@ -1409,7 +1518,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
name: editName.value.trim(),
|
name: editName.value.trim(),
|
||||||
notes: editNotes.value,
|
notes: editNotes.value,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
metadata: meta
|
metadata: meta,
|
||||||
|
parent_ids: selectedParents.map(function (parent) { return parent.id; })
|
||||||
};
|
};
|
||||||
showEditErr('');
|
showEditErr('');
|
||||||
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
|
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) {
|
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
|
||||||
var viewBtn = tr.querySelector('.btn-view-secrets');
|
var viewBtn = tr.querySelector('.btn-view-secrets');
|
||||||
if (viewBtn) {
|
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;
|
if (!hasSecrets) viewBtn.disabled = true;
|
||||||
viewBtn.addEventListener('click', function () { openView(tr); });
|
viewBtn.addEventListener('click', function () { openView(tr); });
|
||||||
}
|
}
|
||||||
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
||||||
tr.querySelector('.btn-del').addEventListener('click', function () {
|
tr.querySelector('.btn-del').addEventListener('click', function () {
|
||||||
var id = tr.getAttribute('data-entry-id');
|
var id = tr.getAttribute('data-entry-id');
|
||||||
var nameEl = tr.querySelector('.cell-name');
|
|
||||||
var name = nameEl ? nameEl.textContent.trim() : '';
|
|
||||||
if (!id) return;
|
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',
|
pageTitleBase: 'Secrets',
|
||||||
navMcp: 'MCP',
|
navMcp: 'MCP',
|
||||||
navEntries: '条目',
|
navEntries: '条目',
|
||||||
|
navTrash: '回收站',
|
||||||
navAudit: '审计',
|
navAudit: '审计',
|
||||||
signOut: '退出',
|
signOut: '退出',
|
||||||
mobileLabelTime: '时间',
|
mobileLabelTime: '时间',
|
||||||
@@ -14,6 +15,7 @@ var I18N_SHARED = {
|
|||||||
pageTitleBase: 'Secrets',
|
pageTitleBase: 'Secrets',
|
||||||
navMcp: 'MCP',
|
navMcp: 'MCP',
|
||||||
navEntries: '條目',
|
navEntries: '條目',
|
||||||
|
navTrash: '回收站',
|
||||||
navAudit: '審計',
|
navAudit: '審計',
|
||||||
signOut: '登出',
|
signOut: '登出',
|
||||||
mobileLabelTime: '時間',
|
mobileLabelTime: '時間',
|
||||||
@@ -25,6 +27,7 @@ var I18N_SHARED = {
|
|||||||
pageTitleBase: 'Secrets',
|
pageTitleBase: 'Secrets',
|
||||||
navMcp: 'MCP',
|
navMcp: 'MCP',
|
||||||
navEntries: 'Entries',
|
navEntries: 'Entries',
|
||||||
|
navTrash: 'Trash',
|
||||||
navAudit: 'Audit',
|
navAudit: 'Audit',
|
||||||
signOut: 'Sign out',
|
signOut: 'Sign out',
|
||||||
mobileLabelTime: 'Time',
|
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>
|
||||||
392
plans/metadata-search-and-entry-relations.md
Normal file
392
plans/metadata-search-and-entry-relations.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
# Metadata Value Search & Entry Relations (DAG)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two new features for secrets-mcp:
|
||||||
|
|
||||||
|
1. **Metadata Value Search** — fuzzy search across all JSON scalar values in `metadata`, excluding keys
|
||||||
|
2. **Entry Relations** — directional parent-child associations between entries (DAG, multiple parents allowed, cycle detection)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1: Metadata Value Search
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The existing `query` parameter in `secrets_find`/`secrets_search` searches `metadata::text ILIKE`, which matches keys, JSON punctuation, and structural characters. Users want to search **only metadata values** (e.g. find entries where any metadata value contains "1.2.3.4", regardless of key name).
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_path_query` to iterate over only scalar values (strings, numbers, booleans), then applies ILIKE matching.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
#### secrets-core
|
||||||
|
|
||||||
|
**`crates/secrets-core/src/service/search.rs`**
|
||||||
|
|
||||||
|
- Add `metadata_query: Option<&'a str>` field to `SearchParams`
|
||||||
|
- In `entry_where_clause_and_next_idx`, when `metadata_query` is set, add:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM jsonb_path_query(
|
||||||
|
entries.metadata,
|
||||||
|
'strict $.** ? (@.type() != "object" && @.type() != "array")'
|
||||||
|
) AS val
|
||||||
|
WHERE (val#>>'{}') ILIKE $N ESCAPE '\'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Bind `ilike_pattern(metadata_query)` at the correct `$N` position in both `fetch_entries_paged` and `count_entries`
|
||||||
|
|
||||||
|
#### secrets-mcp (MCP tools)
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/src/tools.rs`**
|
||||||
|
|
||||||
|
- Add `metadata_query` field to `FindInput`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
|
||||||
|
metadata_query: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add same field to `SearchInput`
|
||||||
|
- Pass `metadata_query` through to `SearchParams` in both `secrets_find` and `secrets_search` handlers
|
||||||
|
|
||||||
|
#### secrets-mcp (Web)
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/src/web/entries.rs`**
|
||||||
|
|
||||||
|
- Add `metadata_query: Option<String>` to `EntriesQuery`
|
||||||
|
- Thread it into all `SearchParams` usages (count, list, folder counts)
|
||||||
|
- Pass it into template context
|
||||||
|
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
|
||||||
|
- Include `metadata_query` in pagination `href` links
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/templates/entries.html`**
|
||||||
|
|
||||||
|
- Add a "metadata 值" text input to the filter bar (after name, before type)
|
||||||
|
- Preserve value in the input on re-render
|
||||||
|
|
||||||
|
### i18n Keys
|
||||||
|
|
||||||
|
| Key | zh | zh-Hant | en |
|
||||||
|
|-----|-----|---------|-----|
|
||||||
|
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
|
||||||
|
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
|
||||||
|
|
||||||
|
### Performance Notes
|
||||||
|
|
||||||
|
- The `jsonb_path_query` with `$.**` scans all nested values recursively; this is a sequential scan on the metadata column per row
|
||||||
|
- The existing GIN index on `metadata jsonb_path_ops` supports `@>` containment queries but NOT this pattern
|
||||||
|
- For production datasets > 10k entries, consider a generated column or materialized search column in a future iteration
|
||||||
|
- First version prioritizes semantic correctness over index optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2: Entry Relations (DAG)
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
New table `entry_relations`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||||
|
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (parent_entry_id, child_entry_id),
|
||||||
|
CHECK (parent_entry_id <> child_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||||
|
CREATE INDEX idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||||
|
|
||||||
|
-- Enforce multi-tenant isolation: parent and child must belong to same user
|
||||||
|
ALTER TABLE entry_relations ADD CONSTRAINT fk_parent_user
|
||||||
|
FOREIGN KEY (parent_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE entry_relations ADD CONSTRAINT fk_child_user
|
||||||
|
FOREIGN KEY (child_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared secrets already use `entry_secrets` as an N:N relation, so this is consistent with the existing pattern.
|
||||||
|
|
||||||
|
### Cycle Detection
|
||||||
|
|
||||||
|
On every `INSERT INTO entry_relations(parent, child)`, check that no path exists from `child` back to `parent`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Returns true if adding (parent, child) would create a cycle
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM entry_relations
|
||||||
|
WHERE child_entry_id = $1 -- $1 = proposed parent
|
||||||
|
START WITH parent_entry_id = $2 -- $2 = proposed child
|
||||||
|
CONNECT BY PRIOR child_entry_id = parent_entry_id
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — PostgreSQL doesn't support `START WITH ... CONNECT BY`. Use recursive CTE instead:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE chain AS (
|
||||||
|
SELECT parent_entry_id AS ancestor
|
||||||
|
FROM entry_relations
|
||||||
|
WHERE child_entry_id = $1 -- proposed child
|
||||||
|
UNION ALL
|
||||||
|
SELECT er.parent_entry_id
|
||||||
|
FROM entry_relations er
|
||||||
|
JOIN chain c ON c.ancestor = er.child_entry_id
|
||||||
|
)
|
||||||
|
SELECT EXISTS(SELECT 1 FROM chain WHERE ancestor = $2);
|
||||||
|
-- $1 = proposed child, $2 = proposed parent
|
||||||
|
```
|
||||||
|
|
||||||
|
If `EXISTS` returns true, reject with `AppError::Validation { message: "cycle detected" }`.
|
||||||
|
|
||||||
|
### secrets-core Changes
|
||||||
|
|
||||||
|
**New file: `crates/secrets-core/src/service/relations.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RelationSummary {
|
||||||
|
pub parent_id: Uuid,
|
||||||
|
pub parent_name: String,
|
||||||
|
pub parent_folder: String,
|
||||||
|
pub parent_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AddRelationParams<'a> {
|
||||||
|
pub parent_entry_id: Uuid,
|
||||||
|
pub child_entry_id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RemoveRelationParams<'a> {
|
||||||
|
pub parent_entry_id: Uuid,
|
||||||
|
pub child_entry_id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a parent→child relation. Validates:
|
||||||
|
/// - Both entries exist and belong to the same user
|
||||||
|
/// - No self-reference (enforced by CHECK constraint)
|
||||||
|
/// - No cycle (recursive CTE check)
|
||||||
|
pub async fn add_relation(pool: &PgPool, params: AddRelationParams<'_>) -> Result<()>
|
||||||
|
|
||||||
|
/// Remove a parent→child relation.
|
||||||
|
pub async fn remove_relation(pool: &PgPool, params: RemoveRelationParams<'_>) -> Result<()>
|
||||||
|
|
||||||
|
/// Get all parents of an entry (with summary info).
|
||||||
|
pub async fn get_parents(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
|
||||||
|
|
||||||
|
/// Get all children of an entry (with summary info).
|
||||||
|
pub async fn get_children(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
|
||||||
|
|
||||||
|
/// Get parents + children for a batch of entry IDs (for list pages).
|
||||||
|
pub async fn get_relations_for_entries(
|
||||||
|
pool: &PgPool,
|
||||||
|
entry_ids: &[Uuid],
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`crates/secrets-core/src/service/mod.rs`** — add `pub mod relations;`
|
||||||
|
|
||||||
|
**`crates/secrets-core/src/db.rs`** — add entry_relations table creation in `migrate()`
|
||||||
|
|
||||||
|
**`crates/secrets-core/src/error.rs`** — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
|
||||||
|
|
||||||
|
### MCP Tool Changes
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/src/tools.rs`**
|
||||||
|
|
||||||
|
1. **`secrets_add`** (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
|
||||||
|
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
|
||||||
|
- After creating the entry, call `relations::add_relation` for each parent
|
||||||
|
|
||||||
|
2. **`secrets_update`** (`UpdateInput`): add two fields:
|
||||||
|
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
|
||||||
|
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
|
||||||
|
|
||||||
|
3. **`secrets_find`** and `secrets_search` output: add `parents` and `children` arrays to each entry result:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"name": "...",
|
||||||
|
"parents": [{"id": "...", "name": "...", "folder": "...", "type": "..."}],
|
||||||
|
"children": [{"id": "...", "name": "...", "folder": "...", "type": "..."}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Fetch relations for all returned entry IDs in a single batch query
|
||||||
|
|
||||||
|
### Web Changes
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/src/web/entries.rs`**
|
||||||
|
|
||||||
|
1. **New API endpoints:**
|
||||||
|
|
||||||
|
- `POST /api/entries/{id}/relations` — add parent relation
|
||||||
|
- Body: `{ "parent_id": "uuid" }`
|
||||||
|
- Validates same-user ownership and cycle detection
|
||||||
|
|
||||||
|
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
|
||||||
|
|
||||||
|
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
|
||||||
|
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
|
||||||
|
- Used by the edit dialog's parent selection autocomplete
|
||||||
|
|
||||||
|
2. **Entry list template data** — include parent/child counts per entry row
|
||||||
|
|
||||||
|
3. **`api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
|
||||||
|
- When present, replace all parent relations for this entry with the given list
|
||||||
|
- This is simpler than incremental add/remove in the Web UI context
|
||||||
|
|
||||||
|
**`crates/secrets-mcp/templates/entries.html`**
|
||||||
|
|
||||||
|
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
|
||||||
|
2. **Edit dialog**: add "上级条目" (parent entries) section
|
||||||
|
- Show current parents as removable chips
|
||||||
|
- Add a search-as-you-type input that queries `/api/entries/options`
|
||||||
|
- Click a search result to add it as parent
|
||||||
|
- On save, send `parent_ids` in the PATCH body
|
||||||
|
3. **View dialog / detail**: show "下级条目" (children) list with clickable links that navigate to the child entry
|
||||||
|
4. **i18n**: add keys for all new UI elements
|
||||||
|
|
||||||
|
### i18n Keys (Entry Relations)
|
||||||
|
|
||||||
|
| Key | zh | zh-Hant | en |
|
||||||
|
|-----|-----|---------|-----|
|
||||||
|
| `colRelations` | 关联 | 關聯 | Relations |
|
||||||
|
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
|
||||||
|
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
|
||||||
|
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
|
||||||
|
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
|
||||||
|
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
|
||||||
|
| `noParents` | 无上级 | 無上級 | No parents |
|
||||||
|
| `noChildren` | 无下级 | 無下級 | No children |
|
||||||
|
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
|
||||||
|
|
||||||
|
### Audit Logging
|
||||||
|
|
||||||
|
Log relation changes in the existing `audit::log_tx` system:
|
||||||
|
|
||||||
|
- Action: `"add_relation"` / `"remove_relation"`
|
||||||
|
- Detail JSON: `{ "parent_id": "...", "parent_name": "...", "child_id": "...", "child_name": "..." }`
|
||||||
|
|
||||||
|
### Export / Import
|
||||||
|
|
||||||
|
**`ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ParentRef {
|
||||||
|
pub folder: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- On export, resolve each entry's parent IDs to `(folder, name)` pairs
|
||||||
|
- On import, two-phase:
|
||||||
|
1. Create all entries (skip parents)
|
||||||
|
2. For each entry with `parents`, resolve `(folder, name)` → `entry_id` and call `add_relation`
|
||||||
|
3. If a parent reference cannot be resolved, log a warning and skip it (don't fail the entire import)
|
||||||
|
|
||||||
|
### History / Rollback
|
||||||
|
|
||||||
|
- Relation changes are **not** versioned in `entries_history`. They are tracked only via `audit_log`.
|
||||||
|
- Rationale: relations are a cross-entry concern; rolling them back alongside entry fields would require complex multi-entry coordination. The audit log provides sufficient traceability.
|
||||||
|
- If the user explicitly requests rollback of relations in the future, it can be implemented as a separate feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Metadata Value Search
|
||||||
|
|
||||||
|
1. `secrets-core/src/service/search.rs` — add `metadata_query` to `SearchParams`, implement SQL condition
|
||||||
|
2. `secrets-mcp/src/tools.rs` — add `metadata_query` to `FindInput` and `SearchInput`, wire through
|
||||||
|
3. `secrets-mcp/src/web/entries.rs` — add `metadata_query` to `EntriesQuery`, `SearchParams`, pagination, folder counts
|
||||||
|
4. `secrets-mcp/templates/entries.html` — add input field, i18n
|
||||||
|
5. Test: existing `query` still works; `metadata_query` only matches values
|
||||||
|
|
||||||
|
### Phase 2: Entry Relations (Core)
|
||||||
|
|
||||||
|
1. `secrets-core/src/db.rs` — add `entry_relations` table to `migrate()`
|
||||||
|
2. `secrets-core/src/service/relations.rs` — implement `add_relation`, `remove_relation`, `get_parents`, `get_children`, `get_relations_for_entries`, cycle detection
|
||||||
|
3. `secrets-core/src/service/mod.rs` — add `pub mod relations`
|
||||||
|
4. Test: add/remove/query relations, cycle detection, same-user validation
|
||||||
|
|
||||||
|
### Phase 3: Entry Relations (MCP)
|
||||||
|
|
||||||
|
1. `secrets-mcp/src/tools.rs` — extend `AddInput`, `UpdateInput` with parent IDs
|
||||||
|
2. `secrets-mcp/src/tools.rs` — extend `secrets_find`/`secrets_search` output with `parents`/`children`
|
||||||
|
3. Test: MCP tools work end-to-end
|
||||||
|
|
||||||
|
### Phase 4: Entry Relations (Web)
|
||||||
|
|
||||||
|
1. `secrets-mcp/src/web/entries.rs` — add API endpoints, extend `EntryPatchBody`, extend template data
|
||||||
|
2. `secrets-mcp/templates/entries.html` — add relations column, edit dialog parent selector, view dialog children list
|
||||||
|
3. Test: Web UI works end-to-end
|
||||||
|
|
||||||
|
### Phase 5: Export / Import (Optional)
|
||||||
|
|
||||||
|
1. `secrets-core/src/models.rs` — add `parents` to `ExportEntry`
|
||||||
|
2. `secrets-core/src/service/export.rs` — populate parents
|
||||||
|
3. `secrets-core/src/service/import.rs` — two-phase import with relation resolution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
Add to `secrets-core/src/db.rs` `migrate()`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_relations (
|
||||||
|
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (parent_entry_id, child_entry_id),
|
||||||
|
CHECK (parent_entry_id <> child_entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Same-user isolation**: `add_relation` must verify both `parent_entry_id` and `child_entry_id` belong to the same `user_id` (or both are `NULL` for legacy single-user mode)
|
||||||
|
- **Cycle detection**: Recursive CTE query prevents any directed cycle, regardless of depth
|
||||||
|
- **CASCADE delete**: When an entry is deleted, all its relation edges are automatically removed via the `ON DELETE CASCADE` foreign key. This is the same pattern used by `entry_secrets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Metadata Search
|
||||||
|
- [ ] `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
|
||||||
|
- [ ] `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
|
||||||
|
- [ ] `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
|
||||||
|
- [ ] `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
|
||||||
|
- [ ] `metadata_query` with special characters (`%`, `_`) is properly escaped
|
||||||
|
- [ ] Existing `query` parameter behavior is unchanged
|
||||||
|
- [ ] Web filter bar preserves `metadata_query` across pagination and folder tab clicks
|
||||||
|
|
||||||
|
### Entry Relations
|
||||||
|
- [ ] Can add a parent→child relation between two entries
|
||||||
|
- [ ] Can add multiple parents to a single entry
|
||||||
|
- [ ] Cannot add self-referencing relation (CHECK constraint)
|
||||||
|
- [ ] Cannot create a direct cycle (A→B→A)
|
||||||
|
- [ ] Cannot create an indirect cycle (A→B→C→A)
|
||||||
|
- [ ] Cannot link entries from different users
|
||||||
|
- [ ] Deleting an entry removes all its relation edges but leaves related entries intact
|
||||||
|
- [ ] MCP `secrets_add` with `parent_ids` creates relations
|
||||||
|
- [ ] MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
|
||||||
|
- [ ] MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
|
||||||
|
- [ ] Web entry list shows relation counts
|
||||||
|
- [ ] Web edit dialog allows adding/removing parents
|
||||||
|
- [ ] Web entry view shows children with navigation links
|
||||||
Reference in New Issue
Block a user