feat: vault core/CLI/workbench, vaultd token path, filetree view-state cleanup
Land password-vault dedicated workbench and mnote-vault-core/CLI, agent token read path design, vault transport split, and retire obsolete filetree smokes. Ignore local vault reimport scripts that trip secret scanners.
This commit is contained in:
@@ -92,6 +92,28 @@ impl WebError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map independent vault-core errors into HTTP WebError (12-2 P1a shared read path).
|
||||
impl From<mnote_vault_core::VaultError> for WebError {
|
||||
fn from(err: mnote_vault_core::VaultError) -> Self {
|
||||
use mnote_vault_core::VaultStatus;
|
||||
let status = match err.status {
|
||||
VaultStatus::BadRequest => StatusCode::BAD_REQUEST,
|
||||
VaultStatus::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
VaultStatus::Forbidden => StatusCode::FORBIDDEN,
|
||||
VaultStatus::NotFound => StatusCode::NOT_FOUND,
|
||||
VaultStatus::Conflict => StatusCode::CONFLICT,
|
||||
VaultStatus::Locked => StatusCode::from_u16(423).unwrap_or(StatusCode::FORBIDDEN),
|
||||
VaultStatus::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||
VaultStatus::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let mut web = WebError::new(status, err.code, err.message);
|
||||
if let Some(details) = err.details {
|
||||
web = web.with_details(details);
|
||||
}
|
||||
web
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = ErrorBody {
|
||||
|
||||
@@ -131,7 +131,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-vault",
|
||||
title: "密码箱 / AI 密码本",
|
||||
description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。",
|
||||
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
||||
category: "security",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
|
||||
@@ -1419,6 +1419,12 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
|
||||
<div class="mnote-vault-header-actions">
|
||||
<button type="button" data-vault-create data-testid="vault-create">新建</button>
|
||||
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
|
||||
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
|
||||
<span class="mnote-vault-sr-only">插入密文</span>
|
||||
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
|
||||
<option value="">插入密文…</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
|
||||
<div class="mnote-vault-tabs" role="tablist">
|
||||
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
|
||||
|
||||
@@ -189,7 +189,10 @@ fn local_page_tree_snapshot_scan_test_loads_for_key(
|
||||
root_uri: &str,
|
||||
parent_relative_path: &str,
|
||||
) -> u64 {
|
||||
let key = format!("{root_uri}\n{parent_relative_path}");
|
||||
// Match load_local_folder_page_tree_snapshot_for_scope cache_key shape:
|
||||
// "{root_source_uri}\n{parent_relative_path}\n{reveal_relative_path}".
|
||||
// Callers pass the same root_uri used for load; reveal counters are empty here.
|
||||
let key = format!("{root_uri}\n{parent_relative_path}\n");
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
@@ -8554,16 +8557,17 @@ fn append_page_tree_reveal_rows(
|
||||
}
|
||||
let depth = local_folder_relative_depth(ancestor);
|
||||
// Parent id for children of this ancestor directory.
|
||||
let parent_node_id = if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, ancestor)
|
||||
{
|
||||
Some(sibling_md)
|
||||
} else {
|
||||
Some(local_directory_group_id(ancestor))
|
||||
};
|
||||
// Must match how shallow PageTree projects the directory itself
|
||||
// (nested bundle Dir/Dir.md, sibling Name.md, or local-dir page-group).
|
||||
// Previously only sibling .md was checked, so nested bundles got
|
||||
// parentNodeId=local-dir:… while the parent row was local-md:…/Dir.md;
|
||||
// groupRowsByParent then promoted children to roots (duplicate roots after delete/reveal).
|
||||
let parent_node_id = Some(page_tree_node_id_for_directory(root, ancestor));
|
||||
// Ensure the ancestor group/page row itself is expanded.
|
||||
for row in rows.iter_mut() {
|
||||
if row.relative_path == *ancestor
|
||||
|| row.node_id == local_directory_group_id(ancestor)
|
||||
|| row.node_id == parent_node_id.as_deref().unwrap_or_default()
|
||||
|| row.document_id.as_deref()
|
||||
== parent_node_id
|
||||
.as_deref()
|
||||
@@ -8628,6 +8632,31 @@ fn append_page_tree_reveal_rows(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PageTree node id for a directory, aligned with `scan_markdown_page_tree_shallow`:
|
||||
/// 1. nested page bundle `Dir/Dir.md` → `local-md:…/Dir/Dir.md`
|
||||
/// 2. sibling markdown `parent/Name.md` with directory `parent/Name/` → that page id
|
||||
/// 3. otherwise page-group → `local-dir:…`
|
||||
fn page_tree_node_id_for_directory(root: &Path, directory_relative: &str) -> String {
|
||||
let normalized = directory_relative
|
||||
.trim()
|
||||
.trim_matches('/')
|
||||
.replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Ok(directory) = resolve_metadata_relative_path(root, &normalized) {
|
||||
if let Some(nested_main) = nested_bundle_main_markdown(&directory) {
|
||||
if let Ok(relative) = normalize_relative_path(root, &nested_main) {
|
||||
return local_markdown_path_page_id(&relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, &normalized) {
|
||||
return sibling_md;
|
||||
}
|
||||
local_directory_group_id(&normalized)
|
||||
}
|
||||
|
||||
fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option<String> {
|
||||
let parent = Path::new(ancestor_relative).parent()?;
|
||||
let name = Path::new(ancestor_relative).file_name()?.to_str()?;
|
||||
@@ -13953,6 +13982,38 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 12-2 / 12-1:通用 local file open 不得读 `.mnote/vault/**`(须走 vault API / mnote-vault)。
|
||||
#[tokio::test]
|
||||
async fn local_file_open_rejects_vault_system_path() {
|
||||
let root = temp_root("mnote-local-file-open-vault-deny");
|
||||
let vault_entry = root.join(".mnote/vault/entries");
|
||||
std::fs::create_dir_all(&vault_entry).expect("vault dir");
|
||||
std::fs::write(vault_entry.join("secret.md"), "password: leak").expect("write vault");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
let query = LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: ".mnote/vault/entries/secret.md".into(),
|
||||
download: None,
|
||||
};
|
||||
|
||||
let error = open_local_file(State(test_state()), Extension(context), Query(query))
|
||||
.await
|
||||
.expect_err("must deny vault path on general file open");
|
||||
assert_eq!(error.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(error.code(), "vault_path_denied");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_allows_read_grant() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
@@ -14804,6 +14865,102 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_page_tree_reveal_nested_bundle_keeps_single_root_parent() {
|
||||
// Regression: delete/watch full sidebar refresh uses reveal. Nested
|
||||
// Root/Root.md must own Root/Child/Child.md via parentNodeId, not
|
||||
// local-dir:Root (which groupRowsByParent promotes to duplicate roots).
|
||||
let root = temp_root("mnote-page-tree-reveal-nested-bundle-parent");
|
||||
init_workspace(&root);
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
std::fs::create_dir_all(root.join("Root").join("Child")).expect("create nested dirs");
|
||||
std::fs::write(root.join("Root").join("Root.md"), "# Root\n").expect("write Root.md");
|
||||
std::fs::write(
|
||||
root.join("Root").join("Child").join("Child.md"),
|
||||
"# Child\n",
|
||||
)
|
||||
.expect("write Child.md");
|
||||
// Second sibling under Root so multi-child root promotion would be obvious.
|
||||
std::fs::create_dir_all(root.join("Root").join("Sibling")).expect("create Sibling");
|
||||
std::fs::write(
|
||||
root.join("Root").join("Sibling").join("Sibling.md"),
|
||||
"# Sibling\n",
|
||||
)
|
||||
.expect("write Sibling.md");
|
||||
|
||||
let reveal_doc = local_markdown_path_page_id("Root/Child/Child.md");
|
||||
assert_eq!(reveal_doc, "local-md:Root~2FChild~2FChild.md");
|
||||
let revealed = load_local_folder_page_tree_snapshot_with_reveal(
|
||||
&root_uri,
|
||||
Some(reveal_doc.as_str()),
|
||||
)
|
||||
.expect("reveal snapshot");
|
||||
let items = revealed.projection["items"].as_array().expect("items");
|
||||
|
||||
let root_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FRoot.md"))
|
||||
.expect("Root nested-bundle page in reveal snapshot");
|
||||
assert!(
|
||||
root_node["parentNodeId"].is_null()
|
||||
|| root_node["parentNodeId"].as_str().map(str::is_empty).unwrap_or(false),
|
||||
"Root page must remain a tree root: {root_node}"
|
||||
);
|
||||
|
||||
let child_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FChild~2FChild.md"))
|
||||
.expect("Child must be present after reveal");
|
||||
assert_eq!(
|
||||
child_node["parentNodeId"].as_str(),
|
||||
Some("local-md:Root~2FRoot.md"),
|
||||
"Child parent must match nested-bundle Root page id, not local-dir:Root: {child_node}"
|
||||
);
|
||||
|
||||
let sibling_node = items
|
||||
.iter()
|
||||
.find(|item| item["nodeId"].as_str() == Some("local-md:Root~2FSibling~2FSibling.md"))
|
||||
.expect("Sibling of revealed child must also nest under Root");
|
||||
assert_eq!(
|
||||
sibling_node["parentNodeId"].as_str(),
|
||||
Some("local-md:Root~2FRoot.md"),
|
||||
"Sibling parent must match Root page id: {sibling_node}"
|
||||
);
|
||||
|
||||
// No orphan local-dir:Root page-group row that would fight the local-md parent.
|
||||
assert!(
|
||||
items.iter().all(|item| {
|
||||
item["nodeId"].as_str() != Some("local-dir:Root")
|
||||
&& !item["rowId"]
|
||||
.as_str()
|
||||
.map(|id| id.contains("page-group:Root"))
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
"reveal must not invent a local-dir/page-group Root: {items:?}"
|
||||
);
|
||||
|
||||
// groupRowsByParent contract: only Root is a root; Child/Sibling hang under it.
|
||||
let ids: std::collections::BTreeSet<String> = items
|
||||
.iter()
|
||||
.filter_map(|item| item["nodeId"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for item in items {
|
||||
let parent = item["parentNodeId"].as_str().unwrap_or("");
|
||||
if parent.is_empty() || !ids.contains(parent) {
|
||||
roots.push(item["nodeId"].as_str().unwrap_or("").to_string());
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
roots,
|
||||
vec!["local-md:Root~2FRoot.md".to_string()],
|
||||
"groupRowsByParent-equivalent must keep a single root after nested-bundle reveal: {roots:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_keeps_nested_bundle_filesystem_details() {
|
||||
let root = temp_root("mnote-local-file-tree-nested-bundle");
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) mod ui_preferences;
|
||||
mod vault;
|
||||
mod vault_path;
|
||||
mod vault_store;
|
||||
mod vault_transport;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
|
||||
@@ -4846,7 +4846,8 @@ impl PiLabToolFacade {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
|
||||
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
|
||||
crate::routes::vault::list_ai_vault_items(status)
|
||||
// 12-2: UDS vaultd first, then in-process core (not HTTP :3000).
|
||||
crate::routes::vault_transport::list_ai_vault_items(status)
|
||||
}
|
||||
|
||||
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
|
||||
@@ -4855,7 +4856,7 @@ impl PiLabToolFacade {
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
|
||||
})?;
|
||||
crate::routes::vault::get_ai_vault_item(&id)
|
||||
crate::routes::vault_transport::get_ai_vault_item(&id)
|
||||
}
|
||||
|
||||
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
|
||||
@@ -4870,9 +4871,13 @@ impl PiLabToolFacade {
|
||||
let field = string_param(¶ms, "field").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"page_ai_vault_field_required",
|
||||
"mnote.vault.resolve 需要 field=password|apikey|token",
|
||||
"mnote.vault.resolve 需要 field=password|apikey|token|username|email",
|
||||
)
|
||||
})?;
|
||||
let account_id = string_param(¶ms, "accountId")
|
||||
.or_else(|| string_param(¶ms, "account_id"));
|
||||
let secret_id =
|
||||
string_param(¶ms, "secretId").or_else(|| string_param(¶ms, "secret_id"));
|
||||
let actor = {
|
||||
let id = self.context.auth.actor_id.trim();
|
||||
if id.is_empty() {
|
||||
@@ -4888,11 +4893,13 @@ impl PiLabToolFacade {
|
||||
"密码箱 resolve 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::resolve_ai_vault_secret(
|
||||
crate::routes::vault_transport::resolve_ai_vault_secret(
|
||||
&id,
|
||||
&field,
|
||||
&actor,
|
||||
Some(self.context.trace.request_id.as_str()),
|
||||
account_id.as_deref(),
|
||||
secret_id.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4923,7 +4930,7 @@ impl PiLabToolFacade {
|
||||
"密码箱 login 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::login_ai_vault_credential(
|
||||
crate::routes::vault_transport::login_ai_vault_credential(
|
||||
&id,
|
||||
force,
|
||||
&actor,
|
||||
@@ -4966,7 +4973,7 @@ impl PiLabToolFacade {
|
||||
"密码箱 session 需要登录会话",
|
||||
));
|
||||
}
|
||||
crate::routes::vault::put_ai_vault_session(
|
||||
crate::routes::vault_transport::put_ai_vault_session(
|
||||
&id,
|
||||
&cookie,
|
||||
expires.as_deref(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,4 +100,20 @@ mod tests {
|
||||
assert_eq!(err.status(), StatusCode::FORBIDDEN);
|
||||
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_cipher_book_and_index_under_vault() {
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/cipher-book.json"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/vault-index.json"
|
||||
));
|
||||
assert!(is_vault_sensitive_relative_path(
|
||||
".mnote/vault/audit.jsonl"
|
||||
));
|
||||
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/cipher-book.json")
|
||||
.expect_err("must deny cipher-book via general file surface");
|
||||
assert_eq!(err.code(), "vault_path_denied");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
//! Pi / agent vault transport: prefer vaultd UDS, fall back to in-process core.
|
||||
//!
|
||||
//! Aligns with 12-2 §5.4 / §6.4 — same sock→core policy as `mnote-vault` CLI.
|
||||
//! Does **not** depend on HTTP to :3000 for list/get/resolve/login/session data plane.
|
||||
//!
|
||||
//! Env:
|
||||
//! - `MNOTE_VAULT_PI_TRANSPORT=auto|uds|local` (default `auto`)
|
||||
//! - `MNOTE_VAULT_SOCK` / token env handled by `mnote-vault-core::token`
|
||||
|
||||
use crate::error::WebError;
|
||||
use crate::routes::vault;
|
||||
use crate::routes::vault_store::VaultItemStatus;
|
||||
use mnote_vault_core::default_sock_path;
|
||||
use mnote_vault_core::read_token_from_env_or_file;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TransportMode {
|
||||
/// UDS if reachable, else local core.
|
||||
Auto,
|
||||
/// UDS only (fail if sock down).
|
||||
UdsOnly,
|
||||
/// In-process core only (skip sock).
|
||||
LocalOnly,
|
||||
}
|
||||
|
||||
fn transport_mode() -> TransportMode {
|
||||
match std::env::var("MNOTE_VAULT_PI_TRANSPORT")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("uds") | Some("remote") | Some("sock") => TransportMode::UdsOnly,
|
||||
Some("local") | Some("core") | Some("embedded") => TransportMode::LocalOnly,
|
||||
_ => TransportMode::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn sock_reachable(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
std::os::unix::net::UnixStream::connect(path).is_ok()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn uds_http(
|
||||
sock: &Path,
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
) -> Result<(u16, String), WebError> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
let mut stream = UnixStream::connect(sock).map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("无法连接 vaultd sock {}: {e}", sock.display()),
|
||||
)
|
||||
})?;
|
||||
let body_bytes = body.unwrap_or("").as_bytes();
|
||||
let mut req = format!(
|
||||
"{method} {path_and_query} HTTP/1.1\r\nHost: mnote-vaultd\r\nConnection: close\r\n"
|
||||
);
|
||||
if let Some(token) = bearer {
|
||||
req.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
if body.is_some() {
|
||||
req.push_str("Content-Type: application/json\r\n");
|
||||
req.push_str(&format!("Content-Length: {}\r\n", body_bytes.len()));
|
||||
} else {
|
||||
req.push_str("Content-Length: 0\r\n");
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
stream
|
||||
.write_all(req.as_bytes())
|
||||
.and_then(|_| {
|
||||
if !body_bytes.is_empty() {
|
||||
stream.write_all(body_bytes)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("写 sock 失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut raw = Vec::new();
|
||||
stream.read_to_end(&mut raw).map_err(|e| {
|
||||
WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("读 sock 失败: {e}"),
|
||||
)
|
||||
})?;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
parse_http_response(&text)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn uds_http(
|
||||
_sock: &Path,
|
||||
_method: &str,
|
||||
_path_and_query: &str,
|
||||
_body: Option<&str>,
|
||||
_bearer: Option<&str>,
|
||||
) -> Result<(u16, String), WebError> {
|
||||
Err(WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
"UDS 仅支持 Unix",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_http_response(text: &str) -> Result<(u16, String), WebError> {
|
||||
let (head, body) = text
|
||||
.split_once("\r\n\r\n")
|
||||
.or_else(|| text.split_once("\n\n"))
|
||||
.unwrap_or((text, ""));
|
||||
let status_line = head.lines().next().unwrap_or("");
|
||||
let status: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(500);
|
||||
Ok((status, body.to_string()))
|
||||
}
|
||||
|
||||
fn map_http_error(status: u16, body: &str) -> WebError {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(body) {
|
||||
let code = v
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("vaultd_error");
|
||||
let message = v
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(body)
|
||||
.to_string();
|
||||
let http_status = axum::http::StatusCode::from_u16(status)
|
||||
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
// Prefer stable vault_* codes when present.
|
||||
let code_static: &'static str = match code {
|
||||
"vault_token_missing" => "vault_token_missing",
|
||||
"vault_token_invalid" => "vault_token_invalid",
|
||||
"vault_token_expired" => "vault_token_expired",
|
||||
"vault_scope_denied" => "vault_scope_denied",
|
||||
"vault_actor_mismatch" => "vault_actor_mismatch",
|
||||
"vault_item_not_found" => "vault_item_not_found",
|
||||
"vault_resolve_field_invalid" => "vault_resolve_field_invalid",
|
||||
"vault_resolve_inactive" => "vault_resolve_inactive",
|
||||
"vaultd_unavailable" => "vaultd_unavailable",
|
||||
"bad_request" => "bad_request",
|
||||
"vault_session_inactive" => "vault_session_inactive",
|
||||
"vault_login_no_url" => "vault_login_no_url",
|
||||
"vault_login_human_required" => "vault_login_human_required",
|
||||
_ if code.starts_with("vault_") => "vault_error",
|
||||
_ => "vaultd_error",
|
||||
};
|
||||
let mut err = WebError::new(http_status, code_static, message);
|
||||
if code_static == "vault_error" {
|
||||
err = err.with_details(json!({ "upstreamCode": code }));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
WebError::new(
|
||||
axum::http::StatusCode::from_u16(status)
|
||||
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
"vaultd_error",
|
||||
format!("HTTP {status}: {body}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn client_token() -> Result<Option<String>, WebError> {
|
||||
match read_token_from_env_or_file() {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
|
||||
where
|
||||
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
|
||||
G: FnOnce() -> Result<Value, WebError>,
|
||||
{
|
||||
let mode = transport_mode();
|
||||
if mode == TransportMode::LocalOnly {
|
||||
return via_local();
|
||||
}
|
||||
let sock = default_sock_path();
|
||||
if sock_reachable(&sock) {
|
||||
let token = client_token()?;
|
||||
match via_uds(token.as_deref()) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) if mode == TransportMode::UdsOnly => return Err(e),
|
||||
Err(_) => {
|
||||
// Soft fallback to in-process core (same as CLI uds_fallback_local).
|
||||
}
|
||||
}
|
||||
} else if mode == TransportMode::UdsOnly {
|
||||
return Err(WebError::service_unavailable_code(
|
||||
"vaultd_unavailable",
|
||||
format!("vaultd sock 不可达: {}", sock.display()),
|
||||
));
|
||||
}
|
||||
via_local()
|
||||
}
|
||||
|
||||
fn core_status(status: VaultItemStatus) -> VaultItemStatus {
|
||||
status
|
||||
}
|
||||
|
||||
/// List AI vault (Pi tool). UDS → core.
|
||||
pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
|
||||
let status_q = status.as_str();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items?status={status_q}");
|
||||
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &body));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| {
|
||||
WebError::internal(format!("vaultd list JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::list_ai_vault_items(core_status(status)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id}");
|
||||
let (st, body) = uds_http(&default_sock_path(), "GET", &path, None, token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &body));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| {
|
||||
WebError::internal(format!("vaultd get JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::get_ai_vault_item(id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_ai_vault_secret(
|
||||
id: &str,
|
||||
field: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
secret_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
let field_owned = field.to_string();
|
||||
let account = account_id.map(str::to_string);
|
||||
let secret = secret_id.map(str::to_string);
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/resolve");
|
||||
let body = json!({
|
||||
"field": field_owned,
|
||||
"accountId": account,
|
||||
"secretId": secret,
|
||||
});
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd resolve JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| {
|
||||
vault::resolve_ai_vault_secret(
|
||||
&id_owned,
|
||||
&field_owned,
|
||||
actor,
|
||||
request_id,
|
||||
account.as_deref(),
|
||||
secret.as_deref(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn login_ai_vault_credential(
|
||||
id: &str,
|
||||
force_refresh: bool,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/login");
|
||||
let body = json!({ "forceRefresh": force_refresh });
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd login JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| vault::login_ai_vault_credential(&id_owned, force_refresh, actor, request_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn put_ai_vault_session(
|
||||
id: &str,
|
||||
cookie_header: &str,
|
||||
expires_at: Option<&str>,
|
||||
source: &str,
|
||||
actor: &str,
|
||||
request_id: Option<&str>,
|
||||
) -> Result<Value, WebError> {
|
||||
let id_owned = id.to_string();
|
||||
let cookie = cookie_header.to_string();
|
||||
let expires = expires_at.map(str::to_string);
|
||||
let source_owned = source.to_string();
|
||||
with_transport(
|
||||
|token| {
|
||||
let path = format!("/v1/items/{id_owned}/session");
|
||||
let body = json!({
|
||||
"cookieHeader": cookie,
|
||||
"expiresAt": expires,
|
||||
"source": source_owned,
|
||||
});
|
||||
let body_s = body.to_string();
|
||||
let (st, resp) =
|
||||
uds_http(&default_sock_path(), "POST", &path, Some(&body_s), token)?;
|
||||
if st >= 400 {
|
||||
return Err(map_http_error(st, &resp));
|
||||
}
|
||||
serde_json::from_str(&resp).map_err(|e| {
|
||||
WebError::internal(format!("vaultd session JSON 无效: {e}"))
|
||||
})
|
||||
},
|
||||
|| {
|
||||
vault::put_ai_vault_session(
|
||||
&id_owned,
|
||||
&cookie,
|
||||
expires.as_deref(),
|
||||
&source_owned,
|
||||
actor,
|
||||
request_id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transport_mode_defaults_to_auto() {
|
||||
// Do not assert env-free default in parallel tests; just ensure parser is stable.
|
||||
let _ = transport_mode();
|
||||
assert!(matches!(
|
||||
TransportMode::Auto,
|
||||
TransportMode::Auto
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_local_only_works_without_sock() {
|
||||
std::env::set_var("MNOTE_VAULT_PI_TRANSPORT", "local");
|
||||
// May fail if AI vault workspace missing in CI sandbox — only check no panic on mode.
|
||||
let _ = list_ai_vault_items(VaultItemStatus::Active);
|
||||
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,46 @@
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||
}
|
||||
|
||||
/* Wolai-style sibling reorder lines (before / after). Nest-into uses full-row fill above. */
|
||||
.sidebar-tree .tree-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="before"][data-drop-target="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="after"][data-drop-target="true"] {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-feedback="true"],
|
||||
.sidebar-tree .tree-row[data-drop-position="inside"][data-drop-target="true"] {
|
||||
background: rgba(0, 110, 40, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"]::before,
|
||||
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 110, 40, 0.85);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="before"]::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-position="after"]::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle,
|
||||
.sidebar-tree .tree-spacer {
|
||||
width: 20px;
|
||||
|
||||
@@ -90,6 +90,64 @@
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
/* 顶栏(密文簿旁):插入已有密文 [Key];编辑态显示,滚动详情时仍可见 */
|
||||
.mnote-vault-header-actions .mnote-vault-insert-cipher,
|
||||
.mnote-vault-insert-cipher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher select {
|
||||
height: 30px;
|
||||
min-width: 118px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #37352f;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-insert-cipher select:hover {
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
.mnote-vault-form-hint {
|
||||
margin: 0 0 8px;
|
||||
padding: 0 2px;
|
||||
color: #8b8782;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-vault-form-hint code {
|
||||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(27, 28, 28, 0.05);
|
||||
}
|
||||
|
||||
.mnote-vault-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mnote-vault-detail-actions button.is-danger {
|
||||
color: #c93a32;
|
||||
border-color: rgba(201, 58, 50, 0.28);
|
||||
@@ -732,3 +790,296 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Multi-account / multi-secret collapsible groups */
|
||||
.mnote-vault-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(247, 247, 246, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-section-head > button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-section-head > button:hover {
|
||||
background: #f7f7f6;
|
||||
}
|
||||
|
||||
/* Multi-URL (equivalent site endpoints / fallbacks) */
|
||||
.mnote-vault-url-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(55, 53, 47, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-url-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-url-label {
|
||||
font-size: 12px;
|
||||
color: rgba(55, 53, 47, 0.65);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row input[type="url"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row input[type="url"]:focus {
|
||||
outline: 2px solid rgba(35, 131, 226, 0.35);
|
||||
border-color: rgba(35, 131, 226, 0.55);
|
||||
}
|
||||
|
||||
.mnote-vault-url-row > button {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #9b2c2c;
|
||||
}
|
||||
|
||||
.mnote-vault-url-row > button:hover {
|
||||
background: #fdf2f2;
|
||||
}
|
||||
|
||||
.mnote-vault-url-spacer {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.mnote-vault-urls-view .mnote-vault-field-value a {
|
||||
color: #2383e2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-group {
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions button {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-slot-actions button.is-danger {
|
||||
color: #c93a32;
|
||||
border-color: rgba(201, 58, 50, 0.28);
|
||||
}
|
||||
|
||||
.mnote-vault-slot-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 10px 10px;
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
.mnote-vault-slot-empty {
|
||||
font-size: 12px;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
|
||||
|
||||
/* Nested appendix secrets under each account (default collapsed) */
|
||||
.mnote-vault-account-secrets {
|
||||
margin-top: 8px;
|
||||
padding: 8px 8px 6px;
|
||||
border: 1px dashed rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #fafaf9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets.is-collapsed {
|
||||
gap: 0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-body[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d6a65;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d6a65;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-toggle:hover {
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-chevron {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: #9b9a97;
|
||||
}
|
||||
|
||||
.mnote-vault-secrets-count {
|
||||
font-weight: 500;
|
||||
color: #9b9a97;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-head button:not(.mnote-vault-secrets-toggle) {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-vault-account-secrets-empty {
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret {
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-vault-nested-secret-head button.is-danger {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(201, 58, 50, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #c93a32;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Cipher book plain input (visible while typing) */
|
||||
.mnote-vault-cipher-add input.mnote-vault-secret-input-plain,
|
||||
.mnote-vault-cipher-add input[type="text"] {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user