feat: advance local-first workspace checklist

- add admin access-policy UI and local access control surfaces

- add local markdown conflict resolution UI and smoke coverage

- add ACP local agent changed-files audit scaffold and read-only write guard

- document current P0-P2 checklist progress and verification evidence
This commit is contained in:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
+201 -2
View File
@@ -1,7 +1,10 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
@@ -153,6 +156,53 @@ pub async fn auth_entry(
Ok(response)
}
pub async fn admin_access_policy_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_admin_required",
"只有管理员可以访问目录授权页面",
)
.with_context(&context));
}
let workspace_name = format!("{} 的空间", state.config().dev_user_name);
let policy_path = local_access_policy_path_display();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::admin::AdminAccessPolicyPage workspace_name={workspace_name} policy_path={policy_path} />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>目录授权</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(context.auth.actor_id.as_str()),
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -178,6 +228,20 @@ pub async fn root_entry(
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let should_render_local_first_landing = !is_local_folder
&& query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
&& query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
&& requested_page_id.is_none();
let (
workspace_id,
workspace_projection,
@@ -195,6 +259,7 @@ pub async fn root_entry(
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access(&context, root_uri)?;
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = snapshot
.dataset
@@ -239,6 +304,26 @@ pub async fn root_entry(
Some("local_folder".to_string()),
Some(root_uri.to_string()),
)
} else if should_render_local_first_landing {
let workspace_id = "local-first-entry".to_string();
let workspace_projection = build_workspace_shell_projection(
&json!({
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
}),
&workspace_id,
None,
"我的空间",
);
(
workspace_id,
workspace_projection,
String::new(),
String::new(),
None,
None,
None,
)
} else {
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
@@ -303,6 +388,7 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -312,6 +398,7 @@ pub async fn root_entry(
workspace_sidebar_html={workspace_sidebar_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
show_admin_access_policy={show_admin_access_policy}
/>
})
};
@@ -360,6 +447,7 @@ pub async fn root_entry(
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
/>
});
let body_extra = format!(
@@ -387,13 +475,14 @@ pub async fn root_entry(
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
{}
{}
</body>
</html>"#,
escape_html(&html_title),
crate::ssr::MNOTE_CSS,
escape_html(context.auth.actor_id.as_str()),
content,
body_extra
))
@@ -1769,6 +1858,111 @@ mod tests {
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
}
#[tokio::test]
async fn root_entry_renders_local_first_landing_without_convex() {
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
}
#[tokio::test]
async fn root_entry_shows_admin_access_policy_entry_only_for_admin_actor() {
let admin_response =
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "admin_real")
.header("x-mnote-actor-type", "admin")
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin response");
assert_eq!(admin_response.status(), StatusCode::OK);
let admin_body = to_bytes(admin_response.into_body(), usize::MAX)
.await
.expect("body");
let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
let user_response =
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("user response");
assert_eq!(user_response.status(), StatusCode::OK);
let user_body = to_bytes(user_response.into_body(), usize::MAX)
.await
.expect("body");
let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
}
#[tokio::test]
async fn admin_access_policy_entry_requires_admin_actor() {
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/admin/access-policy")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("user response");
assert_eq!(user_response.status(), StatusCode::FORBIDDEN);
let admin_response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/admin/access-policy")
.header("x-mnote-actor-id", "admin_real")
.header("x-mnote-actor-type", "admin")
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin response");
assert_eq!(admin_response.status(), StatusCode::OK);
let body = to_bytes(admin_response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
assert!(html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
assert!(html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
}
#[tokio::test]
async fn root_entry_renders_local_folder_without_debug_tree_route() {
let root =
@@ -1780,6 +1974,11 @@ mod tests {
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init local workspace");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()