fix auth registration and access management ui
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user