fix auth registration and access management ui

This commit is contained in:
lix-2026
2026-05-22 01:47:40 +08:00
parent fdb20300e9
commit 531e845600
16 changed files with 1316 additions and 410 deletions
+324 -2
View File
@@ -127,6 +127,7 @@ pub async fn auth_api(
return Ok(build_sign_out_response(&context));
}
let payload = resolve_auth_login_payload(&state, &context, payload).await?;
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
Ok(build_auth_proxy_response(&convex_response, &context, &payload))
}
@@ -191,14 +192,14 @@ pub async fn admin_access_policy_entry(
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} />
<crate::ssr::pages::admin::AdminAccessPolicyPage workspace_name={workspace_name} policy_path={policy_path} is_admin=true />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>目录授权</title>
<title>授权管理</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
@@ -214,6 +215,44 @@ pub async fn admin_access_policy_entry(
Ok(response)
}
pub async fn user_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);
}
let workspace_name = format!("{} 的空间", state.config().dev_user_name);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::admin::AdminAccessPolicyPage workspace_name={workspace_name} is_admin=false />
});
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="user-access-policy" 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>,
@@ -1627,6 +1666,168 @@ async fn run_convex_auth_action(
Ok(value)
}
async fn resolve_auth_login_payload(
state: &AppState,
context: &RequestContext,
mut payload: serde_json::Value,
) -> Result<serde_json::Value, WebError> {
let action = payload
.get("action")
.and_then(Value::as_str)
.unwrap_or_default();
if action != "auth:signIn" {
return Ok(payload);
}
let flow = payload
.pointer("/args/params/flow")
.and_then(Value::as_str)
.unwrap_or("signIn");
if flow != "signIn" {
let email = non_empty_json_string(payload.pointer("/args/params/email"));
if email.is_none() {
return Err(WebError::bad_request_code(
"auth_signup_email_required",
"注册账号时请填写邮箱;登录时可以使用用户名。",
)
.with_context(context)
.with_header("x-error-phase", "auth_signup_payload"));
}
return Ok(payload);
}
let account = non_empty_json_string(payload.pointer("/args/params/account"))
.or_else(|| non_empty_json_string(payload.pointer("/args/params/email")));
let Some(account) = account else {
return Ok(payload);
};
if account.contains('@') {
payload["args"]["params"]["email"] = Value::String(account.clone());
if payload.pointer("/args/params/name").is_none() {
if let Some(name) = account
.split('@')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload["args"]["params"]["name"] = Value::String(name.to_string());
}
}
return Ok(payload);
}
let resolved = resolve_login_account_via_convex(state, context, &account).await?;
let email = resolved
.get("email")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("auth_username_not_found", "未找到这个用户名。")
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup")
.with_header("x-upstream-service", "convex")
})?;
payload["args"]["params"]["email"] = Value::String(email.to_string());
payload["args"]["params"]["name"] = Value::String(account);
Ok(payload)
}
async fn resolve_login_account_via_convex(
state: &AppState,
context: &RequestContext,
account: &str,
) -> Result<Value, WebError> {
let convex_url = state
.config()
.convex_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::service_unavailable_code(
"convex_config_missing",
"缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法按用户名登录。",
)
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup")
.with_header("x-upstream-service", "convex")
})?;
let request_body = json!({
"path": "users:resolveLoginAccount",
"format": "convex_encoded_json",
"args": [{ "account": account }],
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| {
WebError::internal(format!("Convex 用户名查询 HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup_client")
.with_header("x-upstream-service", "convex")
})?;
let response = client
.post(format!("{}/api/query", convex_url.trim_end_matches('/')))
.header("Content-Type", "application/json")
.header("Convex-Client", "mnote-web")
.json(&request_body)
.send()
.await
.map_err(|error| {
WebError::service_unavailable_code(
"convex_unavailable",
format!("Convex 用户名查询请求失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup")
.with_header("x-upstream-service", "convex")
})?;
let status = response.status();
let body: Value = response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_bad_response",
format!("Convex 用户名查询响应解析失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup_decode")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
if !status.is_success() {
let message = body
.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("用户名查询失败");
return Err(WebError::bad_gateway_code("convex_upstream_error", message.to_string())
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup_status")
.with_header("x-upstream-service", "convex")
.with_header("x-upstream-status", status.as_u16().to_string()));
}
match body.get("status").and_then(Value::as_str) {
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
Some("error") => Err(WebError::bad_request_code(
"auth_username_not_found",
body.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("未找到这个用户名。")
.to_string(),
)
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup")
.with_header("x-upstream-service", "convex")),
_ => Err(WebError::bad_gateway_code(
"convex_bad_response",
format!("未知 Convex 用户名查询响应: {body}"),
)
.with_context(context)
.with_header("x-error-phase", "auth_username_lookup_payload")
.with_header("x-upstream-service", "convex")),
}
}
fn build_auth_proxy_response(
convex_response: &serde_json::Value,
context: &RequestContext,
@@ -2596,6 +2797,33 @@ mod tests {
assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
}
#[tokio::test]
async fn user_access_policy_entry_renders_user_share_management() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/user/access-policy")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("user access policy 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-mnote-shell="user-access-policy""#));
assert!(html.contains("分享管理"));
assert!(html.contains("仅管理员可见"));
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 =
@@ -2843,6 +3071,100 @@ mod tests {
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
}
#[tokio::test]
async fn auth_api_resolves_username_before_convex_sign_in() {
let captured = std::sync::Arc::new(std::sync::Mutex::new(None::<serde_json::Value>));
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("convex auth listener");
let addr = listener.local_addr().expect("convex auth addr");
let captured_body = captured.clone();
let app = axum::Router::new().route(
"/api/query",
post(|| async { axum::Json(serde_json::json!({"status":"success","value":{"email":"mnote.e2e@example.com"}})) }),
)
.route(
"/api/action",
post(move |body: String| {
let captured_body = captured_body.clone();
async move {
*captured_body.lock().expect("captured body") = serde_json::from_str(&body).ok();
axum::Json(serde_json::json!({
"status": "success",
"value": {
"userId": "user_demo",
"tokens": {
"token": "jwt-demo",
"refreshToken": "refresh-demo"
}
}
}))
}
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.expect("convex auth server");
});
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some(format!("http://{addr}")),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"account":"mnote-e2e","password":"MnoteE2E123!","flow":"signIn"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let payload = captured.lock().expect("captured body").clone().expect("captured payload");
assert_eq!(payload["path"], "auth:signIn");
assert_eq!(payload["args"][0]["params"]["email"], "mnote.e2e@example.com");
assert_eq!(payload["args"][0]["params"]["name"], "mnote-e2e");
}
#[tokio::test]
async fn auth_api_requires_email_for_signup() {
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some("http://127.0.0.1:9".into()),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"flow":"signUp","name":"new-user","password":"MnoteE2E123!"}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "auth_signup_email_required");
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱")
);
}
#[tokio::test]
async fn auth_api_normalizes_session_subject_before_setting_actor_cookie() {
let token = unsigned_jwt_with_subject("user_stable|session_rotating");