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
+4
View File
@@ -9,6 +9,8 @@
*/
import type * as aiSessions from "../aiSessions.js";
import type * as auth from "../auth.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
@@ -18,6 +20,8 @@ import type {
declare const fullApi: ApiFromModules<{
aiSessions: typeof aiSessions;
auth: typeof auth;
users: typeof users;
}>;
/**
+8
View File
@@ -0,0 +1,8 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};
+19
View File
@@ -0,0 +1,19 @@
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Password({
validatePasswordRequirements: (password: string) => {
if (password.length < 8) {
throw new Error("密码至少需要 8 个字符");
}
},
profile(params) {
const email = typeof params.email === "string" ? params.email : String(params.email ?? "");
const name = typeof params.name === "string" ? params.name.trim() : "";
return { email, name };
},
}),
],
});
+3
View File
@@ -1,7 +1,10 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { authTables } from "@convex-dev/auth/server";
export default defineSchema({
...authTables,
acp_runtime_runs: defineTable({
id: v.string(),
user_id: v.string(),
+77
View File
@@ -0,0 +1,77 @@
import { query } from "./_generated/server";
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
function normalizeAccount(value: string) {
return value.trim().toLowerCase();
}
export const resolveLoginAccount = query({
args: {
account: v.string(),
},
handler: async (ctx, args) => {
const account = normalizeAccount(args.account);
if (!account) {
return null;
}
const users = await ctx.db.query("users").collect();
const user = users.find((row: any) => {
const email = normalizeAccount(String(row.email ?? ""));
const name = normalizeAccount(String(row.name ?? ""));
return email === account || name === account;
});
if (!user) {
return null;
}
return {
userId: user._id,
email: String((user as any).email ?? ""),
name: String((user as any).name ?? ""),
};
},
});
function normalizeUsername(raw: string): string {
const username = String(raw ?? "").trim();
if (!username) throw new Error("用户名不能为空");
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
return username;
}
export const currentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
return null;
}
return await ctx.db.get(userId);
},
});
export const setMyUsername = mutation({
args: { username: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (userId === null) throw new Error("未登录");
const username = normalizeUsername(args.username);
const existing = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("name"), username))
.first();
if (existing && String(existing._id) !== String(userId)) {
throw new Error("用户名已被占用");
}
await ctx.db.patch(userId, { name: username });
return { ok: true, username };
},
});
@@ -0,0 +1,31 @@
# 2-7 Auth Profile Access Management UI v1
## Goal
修复登录/注册参数口径,并把账号菜单、个人信息、管理员目录授权、普通用户分享管理收口为一套清晰的授权管理体验。
## Scope
- 登录只接受邮箱或用户名 + 密码。
- 注册必须填写邮箱、用户名、密码。
- `/admin/access-policy` 保留给管理员,包含目录授权和分享管理。
- `/user/access-policy` 面向普通用户,只展示分享管理。
- 账号三点菜单改为轻菜单,个人信息进入独立弹窗。
## Checklist
- [x] 注册表单恢复独立邮箱与用户名字段,payload 明确传 `email``name`
- [x] Rust gateway 仅在 `signIn` 且输入不是邮箱时解析用户名;`signUp` 缺邮箱直接返回清晰错误。
- [x] 账号菜单显示 `个人信息``授权管理``退出登录`,个人信息弹窗支持复制用户 ID。
- [x] 新增 `/user/access-policy` route,普通用户访问分享管理;管理员入口继续使用 `/admin/access-policy`
- [x] 重设计授权管理页面:普通字段优先,JSON 调试信息折叠显示。
- [x] 补 Rust 单测与浏览器 smoke:注册字段、用户名登录、个人信息弹窗、管理员/普通用户授权页入口。
## Verification
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web auth_api_resolves_username_before_convex_sign_in`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web auth_entry_uses_mnote_web_login_ui_when_compat_enabled`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web admin_access_policy_page_renders_admin_controls`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web user_access_policy_entry_renders_user_share_management`
- 浏览器 smoke`node scripts/task489-auth-profile-access-ui-smoke.js` 验证 `/auth`、账号菜单、`/admin/access-policy``/user/access-policy`
- 提交前运行 `codegraph sync .``codegraph status .`
+4 -1
View File
@@ -8,7 +8,10 @@
"dev:hot": "node scripts/dev-hot.js",
"check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js"
},
"dependencies": {},
"dependencies": {
"@auth/core": "0.37.0",
"@convex-dev/auth": "0.0.91"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"convex": "^1.39.1",
+165
View File
@@ -7,6 +7,13 @@ settings:
importers:
.:
dependencies:
'@auth/core':
specifier: 0.37.0
version: 0.37.0
'@convex-dev/auth':
specifier: 0.0.91
version: 0.0.91(@auth/core@0.37.0)(convex@1.39.1)
devDependencies:
'@playwright/test':
specifier: ^1.56.1
@@ -20,6 +27,31 @@ importers:
packages:
'@auth/core@0.37.0':
resolution: {integrity: sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@convex-dev/auth@0.0.91':
resolution: {integrity: sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==}
hasBin: true
peerDependencies:
'@auth/core': ^0.37.0
convex: ^1.17.0
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
react:
optional: true
'@esbuild/aix-ppc64@0.27.0':
resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==}
engines: {node: '>=18'}
@@ -176,11 +208,29 @@ packages:
cpu: [x64]
os: [win32]
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'}
hasBin: true
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
convex@1.39.1:
resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
@@ -200,6 +250,14 @@ packages:
react:
optional: true
cookie@0.7.1:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
esbuild@0.27.0:
resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==}
engines: {node: '>=18'}
@@ -210,6 +268,27 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
is-network-error@1.3.2:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
lucia@3.2.2:
resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==}
deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
engines: {node: '>=18'}
@@ -220,11 +299,25 @@ packages:
engines: {node: '>=18'}
hasBin: true
preact-render-to-string@5.2.3:
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies:
preact: '>=10'
preact@10.11.3:
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
@@ -239,6 +332,31 @@ packages:
snapshots:
'@auth/core@0.37.0':
dependencies:
'@panva/hkdf': 1.2.1
'@types/cookie': 0.6.0
cookie: 0.7.1
jose: 5.10.0
oauth4webapi: 3.8.6
preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3)
'@convex-dev/auth@0.0.91(@auth/core@0.37.0)(convex@1.39.1)':
dependencies:
'@auth/core': 0.37.0
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
convex: 1.39.1
cookie: 1.1.1
is-network-error: 1.3.2
jose: 5.10.0
jwt-decode: 4.0.0
lucia: 3.2.2
oauth4webapi: 3.8.6
path-to-regexp: 6.3.0
server-only: 0.0.1
'@esbuild/aix-ppc64@0.27.0':
optional: true
@@ -317,10 +435,27 @@ snapshots:
'@esbuild/win32-x64@0.27.0':
optional: true
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
'@oslojs/binary@1.0.0': {}
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
'@oslojs/encoding@1.1.0': {}
'@panva/hkdf@1.2.1': {}
'@playwright/test@1.57.0':
dependencies:
playwright: 1.57.0
'@types/cookie@0.6.0': {}
convex@1.39.1:
dependencies:
esbuild: 0.27.0
@@ -330,6 +465,10 @@ snapshots:
- bufferutil
- utf-8-validate
cookie@0.7.1: {}
cookie@1.1.1: {}
esbuild@0.27.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.0
@@ -362,6 +501,21 @@ snapshots:
fsevents@2.3.2:
optional: true
is-network-error@1.3.2: {}
jose@5.10.0: {}
jwt-decode@4.0.0: {}
lucia@3.2.2:
dependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
oauth4webapi@3.8.6: {}
path-to-regexp@6.3.0: {}
playwright-core@1.57.0: {}
playwright@1.57.0:
@@ -370,6 +524,17 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
preact-render-to-string@5.2.3(preact@10.11.3):
dependencies:
preact: 10.11.3
pretty-format: 3.8.0
preact@10.11.3: {}
prettier@3.8.3: {}
pretty-format@3.8.0: {}
server-only@0.0.1: {}
ws@8.18.0: {}
+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");
@@ -850,16 +850,40 @@ fn share_grant_payload(grant: &LocalShareGrant) -> Value {
})
}
fn list_local_share_grants_for_context(context: &RequestContext) -> Result<Value, WebError> {
require_share_grants_admin(context)?;
fn list_local_share_grants_for_context(
context: &RequestContext,
admin_required: bool,
) -> Result<Value, WebError> {
let is_admin = is_local_access_policy_admin_context(context);
if admin_required {
require_share_grants_admin(context)?;
} else {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"local_share_grants_auth_required",
"查看分享授权需要先登录",
));
}
}
let store = load_local_share_grants_store()?;
let grants = store
.grants
.iter()
.filter(|grant| {
if is_admin {
true
} else {
let actor_id = context.auth.actor_id.trim();
grant.owner_user_id.trim() == actor_id || grant.target_user_id.trim() == actor_id
}
})
.map(share_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"admin": is_admin,
"grantsPath": local_share_grants_path().display().to_string(),
"grants": grants,
"store": {
@@ -873,6 +897,30 @@ fn add_local_share_grant_for_context(
request: LocalShareGrantRequest,
) -> Result<Value, WebError> {
require_share_grants_admin(context)?;
add_local_share_grant_for_context_inner(context, request, true)
}
fn add_user_share_grant_for_context(
context: &RequestContext,
mut request: LocalShareGrantRequest,
) -> Result<Value, WebError> {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"local_share_grants_auth_required",
"创建分享授权需要先登录",
));
}
request.owner_user_id = actor_id.to_string();
add_local_share_grant_for_context_inner(context, request, false)
}
fn add_local_share_grant_for_context_inner(
context: &RequestContext,
request: LocalShareGrantRequest,
admin_mode: bool,
) -> Result<Value, WebError> {
let mut store = load_local_share_grants_store()?;
let share_id = request.share_id.trim();
let owner_user_id = request.owner_user_id.trim();
@@ -896,6 +944,9 @@ fn add_local_share_grant_for_context(
));
}
let canonical = canonical_root_from_admin_request(&request.root_uri, &request.root_path)?;
if !admin_mode {
ensure_local_workspace_read_access(context, &file_uri_for_path(&canonical))?;
}
let permission = normalize_share_grant_permission(&request.permission)?;
let capabilities = normalize_share_grant_capabilities(&request.capabilities)?;
let root_uri = file_uri_for_path(&canonical);
@@ -951,6 +1002,29 @@ fn revoke_local_share_grant_for_context(
share_id: &str,
) -> Result<Value, WebError> {
require_share_grants_admin(context)?;
revoke_local_share_grant_for_context_inner(context, share_id, true)
}
fn revoke_user_share_grant_for_context(
context: &RequestContext,
share_id: &str,
) -> Result<Value, WebError> {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"local_share_grants_auth_required",
"撤销分享授权需要先登录",
));
}
revoke_local_share_grant_for_context_inner(context, share_id, false)
}
fn revoke_local_share_grant_for_context_inner(
context: &RequestContext,
share_id: &str,
admin_mode: bool,
) -> Result<Value, WebError> {
let mut store = load_local_share_grants_store()?;
let share_id = share_id.trim();
if share_id.is_empty() {
@@ -963,6 +1037,13 @@ fn revoke_local_share_grant_for_context(
let mut updated = None;
for grant in &mut store.grants {
if grant.share_id.trim() == share_id || grant.id.trim() == share_id {
if !admin_mode && grant.owner_user_id.trim() != context.auth.actor_id.trim() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_owner_required",
"只能撤销自己创建的分享授权",
));
}
grant.active = false;
grant.revoked_at = Some(now.clone());
updated = Some(grant.clone());
@@ -1613,7 +1694,15 @@ pub async fn delete_local_access_grant(
pub async fn get_share_grants(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = list_local_share_grants_for_context(&context)
let payload = list_local_share_grants_for_context(&context, true)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
pub async fn get_user_share_grants(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = list_local_share_grants_for_context(&context, false)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -1636,6 +1725,24 @@ pub async fn delete_share_grant(
Ok((StatusCode::OK, Json(payload)))
}
pub async fn create_user_share_grant(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalShareGrantRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = add_user_share_grant_for_context(&context, request)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
pub async fn delete_user_share_grant(
Extension(context): Extension<RequestContext>,
AxumPath(share_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = revoke_user_share_grant_for_context(&context, &share_id)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
pub async fn record_shared_cache(
Extension(context): Extension<RequestContext>,
Json(request): Json<SharedCacheRecordRequest>,
+13
View File
@@ -53,6 +53,10 @@ pub fn build_router(state: AppState) -> Router {
"/admin/access-policy",
get(gateway::admin_access_policy_entry),
)
.route(
"/user/access-policy",
get(gateway::user_access_policy_entry),
)
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route(
@@ -136,6 +140,15 @@ pub fn build_router(state: AppState) -> Router {
"/api/admin/share-grants/{share_id}",
delete(local_folder_source::delete_share_grant),
)
.route(
"/api/user/share-grants",
get(local_folder_source::get_user_share_grants)
.post(local_folder_source::create_user_share_grant),
)
.route(
"/api/user/share-grants/{share_id}",
delete(local_folder_source::delete_user_share_grant),
)
.route(
"/api/local-folder/shared-cache/record",
post(local_folder_source::record_shared_cache),
+256 -130
View File
@@ -8,6 +8,7 @@ pub fn AdminAccessPolicyPage(
#[prop(optional)] workspace_name: Option<String>,
#[prop(optional)] policy_path: Option<String>,
#[prop(optional)] share_grants_path: Option<String>,
#[prop(optional, default = true)] is_admin: bool,
) -> impl IntoView {
let workspace_name = workspace_name
.unwrap_or_else(|| "开发用户 的空间".to_string())
@@ -17,12 +18,17 @@ pub fn AdminAccessPolicyPage(
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string());
let share_grants_path = share_grants_path
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/share-grants.json".to_string());
let page_title = "授权管理".to_string();
view! {
<PageLayout current_nav="admin" workspace_name={workspace_name.clone()} topbar_title={"目录授权".to_string()} show_admin_access_policy=true>
<PageLayout current_nav={if is_admin { "admin" } else { "home" }} workspace_name={workspace_name.clone()} topbar_title={page_title.clone()} show_admin_access_policy={is_admin}>
<main class="mnote-admin-policy-page" data-testid="mnote-admin-access-policy-page">
<header class="mnote-admin-policy-header">
<h1>"目录授权"</h1>
<p>"管理员可以查看、验证和管理本地目录授权,普通用户不会看到入口。"</p>
<h1>{page_title.clone()}</h1>
<p>{if is_admin {
"管理员可以管理目录授权和分享授权。"
} else {
"查看与你相关的分享授权。"
}}</p>
</header>
<section class="mnote-admin-policy-summary">
@@ -32,7 +38,7 @@ pub fn AdminAccessPolicyPage(
</div>
<div class="mnote-admin-policy-summary-item">
<span class="mnote-admin-policy-summary-label">"策略文件"</span>
<code data-testid="mnote-admin-policy-path">{policy_path.clone()}</code>
<code data-testid="mnote-admin-policy-path">{if is_admin { policy_path.clone() } else { "仅管理员可见".to_string() }}</code>
</div>
<div class="mnote-admin-policy-summary-item">
<span class="mnote-admin-policy-summary-label">"分享授权文件"</span>
@@ -40,102 +46,149 @@ pub fn AdminAccessPolicyPage(
</div>
</section>
<section class="mnote-admin-policy-panel">
<header class="mnote-admin-policy-panel-header">
<h2>"当前策略"</h2>
<button type="button" data-testid="mnote-admin-policy-refresh" data-admin-action="refresh-policy">"刷新"</button>
</header>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
</section>
{if is_admin {
view! {
<section class="mnote-admin-policy-panel" data-admin-section="directory">
<header class="mnote-admin-policy-panel-header">
<div>
<h2>"目录授权"</h2>
<p class="mnote-admin-policy-note">"给指定用户授权可访问的本地目录。"</p>
</div>
<button type="button" data-testid="mnote-admin-policy-refresh" data-admin-action="refresh-policy">"刷新"</button>
</header>
<div class="mnote-admin-policy-table" data-testid="mnote-admin-policy-grants-list">
<div class="mnote-admin-policy-empty">"正在读取目录授权..."</div>
</div>
<details class="mnote-admin-policy-debug">
<summary>"查看策略 JSON"</summary>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
</details>
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
</section>
}.into_any()
} else {
view! {
<section class="mnote-admin-policy-panel" data-admin-section="directory" hidden>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
</section>
}.into_any()
}}
<section class="mnote-admin-policy-grid">
<form class="mnote-admin-policy-form" data-admin-form="validate-root">
<header><h2>"验证目录"</h2></header>
<label>
<span>"rootUri"</span>
<input data-testid="mnote-admin-root-uri" name="rootUri" type="text" placeholder="file:///mnt/Data1T/Mnote_data/users/..." />
</label>
<label>
<span>"rootPath"</span>
<input data-testid="mnote-admin-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/..." />
</label>
<button type="submit" data-testid="mnote-admin-validate-root-submit">"验证"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-validate-result"></pre>
</form>
{if is_admin {
view! {
<section class="mnote-admin-policy-grid" data-admin-only="true">
<form class="mnote-admin-policy-form" data-admin-form="validate-root">
<header><h2>"验证目录"</h2></header>
<label>
<span>"rootUri"</span>
<input data-testid="mnote-admin-root-uri" name="rootUri" type="text" placeholder="file:///mnt/Data1T/Mnote_data/users/..." />
</label>
<label>
<span>"rootPath"</span>
<input data-testid="mnote-admin-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/..." />
</label>
<button type="submit" data-testid="mnote-admin-validate-root-submit">"验证"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-validate-result"></pre>
</form>
<form class="mnote-admin-policy-form" data-admin-form="create-grant">
<header><h2>"新增授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-grant-id" name="grantId" type="text" placeholder="可留空自动生成" />
</label>
<label>
<span>"userId"</span>
<input data-testid="mnote-admin-grant-user-id" name="userId" type="text" placeholder="user_123" required />
</label>
<label>
<span>"rootUri"</span>
<input data-testid="mnote-admin-grant-root-uri" name="rootUri" type="text" />
</label>
<label>
<span>"rootPath"</span>
<input data-testid="mnote-admin-grant-root-path" name="rootPath" type="text" />
</label>
<label>
<span>"permission"</span>
<select data-testid="mnote-admin-grant-permission" name="permission">
<option value="read">"read"</option>
<option value="write">"write"</option>
</select>
</label>
<label>
<span>"recursive"</span>
<input data-testid="mnote-admin-grant-recursive" name="recursive" type="checkbox" checked=true />
</label>
<label>
<span>"capabilities"</span>
<input data-testid="mnote-admin-grant-capabilities" name="capabilities" type="text" placeholder="ai,share" />
</label>
<button type="submit" data-testid="mnote-admin-create-grant-submit">"创建"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-result"></pre>
</form>
<form class="mnote-admin-policy-form" data-admin-form="create-grant">
<header><h2>"新增授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-grant-id" name="grantId" type="text" placeholder="可留空自动生成" />
</label>
<label>
<span>"userId"</span>
<input data-testid="mnote-admin-grant-user-id" name="userId" type="text" placeholder="user_123" required />
</label>
<label>
<span>"rootUri"</span>
<input data-testid="mnote-admin-grant-root-uri" name="rootUri" type="text" />
</label>
<label>
<span>"rootPath"</span>
<input data-testid="mnote-admin-grant-root-path" name="rootPath" type="text" />
</label>
<label>
<span>"permission"</span>
<select data-testid="mnote-admin-grant-permission" name="permission">
<option value="read">"read"</option>
<option value="write">"write"</option>
</select>
</label>
<label>
<span>"recursive"</span>
<input data-testid="mnote-admin-grant-recursive" name="recursive" type="checkbox" checked=true />
</label>
<label>
<span>"capabilities"</span>
<input data-testid="mnote-admin-grant-capabilities" name="capabilities" type="text" placeholder="ai,share" />
</label>
<button type="submit" data-testid="mnote-admin-create-grant-submit">"创建"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-result"></pre>
</form>
<form class="mnote-admin-policy-form" data-admin-form="delete-grant">
<header><h2>"删除授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-delete-grant-id" name="grantId" type="text" placeholder="grant_xxx" required />
</label>
<button type="submit" data-testid="mnote-admin-delete-grant-submit">"删除"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre>
</form>
</section>
<form class="mnote-admin-policy-form" data-admin-form="delete-grant">
<header><h2>"删除授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-delete-grant-id" name="grantId" type="text" placeholder="grant_xxx" required />
</label>
<button type="submit" data-testid="mnote-admin-delete-grant-submit">"删除"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre>
</form>
</section>
}.into_any()
} else {
view! {}.into_any()
}}
<section class="mnote-admin-policy-panel" data-testid="mnote-admin-share-grants-panel">
<header class="mnote-admin-policy-panel-header">
<h2>"分享授权"</h2>
<div>
<h2>"分享管理"</h2>
<p class="mnote-admin-policy-note">{if is_admin {
"查看、创建和撤销分享授权。"
} else {
"查看你创建或接收的分享授权。"
}}</p>
</div>
<button type="button" data-testid="mnote-admin-share-grants-refresh" data-admin-action="refresh-share-grants">"刷新"</button>
</header>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-share-grants-json">{""}</pre>
<div class="mnote-admin-policy-table" data-testid="mnote-admin-share-grants-list">
<div class="mnote-admin-policy-empty">"正在读取分享授权..."</div>
</div>
<details class="mnote-admin-policy-debug">
<summary>"查看分享授权 JSON"</summary>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-share-grants-json">{""}</pre>
</details>
<div class="mnote-admin-policy-note" data-testid="mnote-admin-share-grants-message"></div>
</section>
<section class="mnote-admin-policy-grid">
<section class="mnote-admin-policy-grid" data-admin-only={if is_admin { "true" } else { "false" }}>
<form class="mnote-admin-policy-form" data-admin-form="create-share-grant">
<header><h2>"新增分享授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-share-grant-id" name="shareGrantId" type="text" placeholder="可留空自动生成" />
</label>
{if is_admin {
view! {
<label>
<span>"ownerUserId"</span>
<input data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" type="text" placeholder="owner_123" required />
</label>
}.into_any()
} else {
view! {
<input type="hidden" data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" value="" />
}.into_any()
}}
<label>
<span>"shareId"</span>
<input data-testid="mnote-admin-share-id" name="shareId" type="text" placeholder="share_xxx" required />
</label>
<label>
<span>"ownerUserId"</span>
<input data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" type="text" placeholder="owner_123" required />
</label>
<label>
<span>"targetUserId"</span>
<input data-testid="mnote-admin-share-target-user-id" name="targetUserId" type="text" placeholder="target_123" required />
@@ -181,6 +234,9 @@ pub fn AdminAccessPolicyPage(
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-share-grant-result"></pre>
</form>
</section>
<script id="__MNOTE_ACCESS_POLICY_PAGE__" type="application/json">
{format!(r#"{{"isAdmin":{}}}"#, if is_admin { "true" } else { "false" })}
</script>
<script>{ADMIN_POLICY_SCRIPT}</script>
</main>
</PageLayout>
@@ -202,12 +258,66 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]');
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
var policyGrantsList = root.querySelector('[data-testid="mnote-admin-policy-grants-list"]');
var shareGrantsList = root.querySelector('[data-testid="mnote-admin-share-grants-list"]');
var pageConfig = (function () {
var node = document.getElementById('__MNOTE_ACCESS_POLICY_PAGE__');
try { return JSON.parse(node ? node.textContent || '{}' : '{}'); } catch (_) { return {}; }
})();
var isAdmin = pageConfig.isAdmin === true;
function setText(node, value) {
if (!node) return;
node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderBadge(value) {
var text = String(value || '').trim() || 'read';
return '<span class="mnote-admin-policy-badge" data-value="' + escapeHtml(text) + '">' + escapeHtml(text) + '</span>';
}
function renderPolicyGrants(payload) {
if (!policyGrantsList) return;
var grants = payload && payload.policy && Array.isArray(payload.policy.grants) ? payload.policy.grants : [];
if (!grants.length) {
policyGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无目录授权</div>';
return;
}
policyGrantsList.innerHTML = grants.map(function(grant) {
return '<article class="mnote-admin-policy-row">' +
'<div><strong>' + escapeHtml(grant.userId || grant.user_id || '未知用户') + '</strong><span>' + escapeHtml(grant.rootPath || grant.root_path || grant.rootUri || grant.root_uri || '') + '</span></div>' +
'<div>' + renderBadge(grant.permission || grant.access) + '</div>' +
'<div><span>' + escapeHtml((grant.capabilities || []).join(', ') || '无能力标记') + '</span></div>' +
'</article>';
}).join('');
}
function renderShareGrants(payload) {
if (!shareGrantsList) return;
var grants = payload && Array.isArray(payload.grants) ? payload.grants : [];
if (!grants.length) {
shareGrantsList.innerHTML = '<div class="mnote-admin-policy-empty">暂无分享授权</div>';
return;
}
shareGrantsList.innerHTML = grants.map(function(grant) {
var active = grant.active === false ? '已撤销' : '有效';
return '<article class="mnote-admin-policy-row">' +
'<div><strong>' + escapeHtml(grant.shareId || grant.id || '未命名分享') + '</strong><span>' + escapeHtml(grant.rootPath || grant.rootUri || '') + '</span></div>' +
'<div>' + renderBadge(grant.permission) + renderBadge(active) + '</div>' +
'<div><span>所有者 ' + escapeHtml(grant.ownerUserId || '') + '</span><span>接收者 ' + escapeHtml(grant.targetUserId || '') + '</span></div>' +
'</article>';
}).join('');
}
function formValues(form) {
var data = new FormData(form);
var capabilities = String(data.get('capabilities') || '')
@@ -262,14 +372,17 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
}
async function refreshPolicy() {
if (!isAdmin) return;
var payload = await requestJson('/api/admin/access-policy', { method: 'GET', headers: {} });
setText(policyJson, payload);
renderPolicyGrants(payload);
setText(message, '已刷新策略');
}
async function refreshShareGrants() {
var payload = await requestJson('/api/admin/share-grants', { method: 'GET', headers: {} });
var payload = await requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', { method: 'GET', headers: {} });
setText(shareGrantsJson, payload);
renderShareGrants(payload);
setText(shareGrantsMessage, '已刷新分享授权');
}
@@ -283,62 +396,71 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || '刷新失败'); });
});
root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
setText(validateResult, '正在验证...');
requestJson('/api/admin/access-policy/validate-root', {
method: 'POST',
body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }),
}).then(function (payload) {
setText(validateResult, payload);
setText(message, '目录验证完成');
}).catch(function (error) {
setText(validateResult, { ok: false, error: error.message || '验证失败' });
setText(message, error.message || '验证失败');
var validateRootForm = root.querySelector('[data-admin-form="validate-root"]');
if (validateRootForm) {
validateRootForm.addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
setText(validateResult, '正在验证...');
requestJson('/api/admin/access-policy/validate-root', {
method: 'POST',
body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }),
}).then(function (payload) {
setText(validateResult, payload);
setText(message, '目录验证完成');
}).catch(function (error) {
setText(validateResult, { ok: false, error: error.message || '验证失败' });
setText(message, error.message || '验证失败');
});
});
});
}
root.querySelector('[data-admin-form="create-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
setText(createResult, '正在创建...');
requestJson('/api/admin/access-policy/grants', {
method: 'POST',
body: JSON.stringify(values),
}).then(function (payload) {
setText(createResult, payload);
setText(message, '授权已创建');
return refreshPolicy();
}).catch(function (error) {
setText(createResult, { ok: false, error: error.message || '创建失败' });
setText(message, error.message || '创建失败');
var createGrantForm = root.querySelector('[data-admin-form="create-grant"]');
if (createGrantForm) {
createGrantForm.addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
setText(createResult, '正在创建...');
requestJson('/api/admin/access-policy/grants', {
method: 'POST',
body: JSON.stringify(values),
}).then(function (payload) {
setText(createResult, payload);
setText(message, '授权已创建');
return refreshPolicy();
}).catch(function (error) {
setText(createResult, { ok: false, error: error.message || '创建失败' });
setText(message, error.message || '创建失败');
});
});
});
}
root.querySelector('[data-admin-form="delete-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
var grantId = values.id;
setText(deleteResult, '正在删除...');
requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), {
method: 'DELETE',
headers: {},
}).then(function (payload) {
setText(deleteResult, payload);
setText(message, '授权已删除');
return refreshPolicy();
}).catch(function (error) {
setText(deleteResult, { ok: false, error: error.message || '删除失败' });
setText(message, error.message || '删除失败');
var deleteGrantForm = root.querySelector('[data-admin-form="delete-grant"]');
if (deleteGrantForm) {
deleteGrantForm.addEventListener('submit', function (event) {
event.preventDefault();
var values = formValues(event.currentTarget);
var grantId = values.id;
setText(deleteResult, '正在删除...');
requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), {
method: 'DELETE',
headers: {},
}).then(function (payload) {
setText(deleteResult, payload);
setText(message, '授权已删除');
return refreshPolicy();
}).catch(function (error) {
setText(deleteResult, { ok: false, error: error.message || '删除失败' });
setText(message, error.message || '删除失败');
});
});
});
}
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = shareGrantFormValues(event.currentTarget);
setText(createShareGrantResult, '正在创建...');
requestJson('/api/admin/share-grants', {
requestJson(isAdmin ? '/api/admin/share-grants' : '/api/user/share-grants', {
method: 'POST',
body: JSON.stringify(values),
}).then(function (payload) {
@@ -356,7 +478,7 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
var data = new FormData(event.currentTarget);
var shareId = String(data.get('deleteShareId') || '').trim();
setText(deleteShareGrantResult, '正在撤销...');
requestJson('/api/admin/share-grants/' + encodeURIComponent(shareId), {
requestJson((isAdmin ? '/api/admin/share-grants/' : '/api/user/share-grants/') + encodeURIComponent(shareId), {
method: 'DELETE',
headers: {},
}).then(function (payload) {
@@ -369,11 +491,15 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
});
});
refreshPolicy().catch(function (error) {
setText(message, error.message || '加载策略失败');
});
if (isAdmin) {
refreshPolicy().catch(function (error) {
setText(message, error.message || '加载策略失败');
renderPolicyGrants(null);
});
}
refreshShareGrants().catch(function (error) {
setText(shareGrantsMessage, error.message || '加载分享授权失败');
renderShareGrants(null);
});
})();
"#;
+61 -55
View File
@@ -24,7 +24,7 @@ pub fn AuthPage() -> impl IntoView {
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
<label class="mnote-auth-field">
<label class="mnote-auth-field" data-auth-field="account">
<span data-auth-account-label>"邮箱或用户名"</span>
<input
id="account"
@@ -35,6 +35,28 @@ pub fn AuthPage() -> impl IntoView {
required
/>
</label>
<label class="mnote-auth-field" data-auth-field="email" hidden>
<span>"邮箱"</span>
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
/>
</label>
<label class="mnote-auth-field" data-auth-field="username" hidden>
<span>"用户名"</span>
<input
id="username"
name="username"
type="text"
autocomplete="username"
placeholder="请输入用户名"
minlength="2"
maxlength="32"
/>
</label>
<label class="mnote-auth-field">
<span>"密码"</span>
<input
@@ -76,6 +98,11 @@ const AUTH_SCRIPT: &str = r#"
var form = root.querySelector('form[data-auth-mode="convex-password"]');
var flowInput = root.querySelector('[data-auth-flow]');
var accountInput = root.querySelector('#account');
var emailInput = root.querySelector('#email');
var usernameInput = root.querySelector('#username');
var accountField = root.querySelector('[data-auth-field="account"]');
var emailField = root.querySelector('[data-auth-field="email"]');
var usernameField = root.querySelector('[data-auth-field="username"]');
var accountLabel = root.querySelector('[data-auth-account-label]');
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
@@ -104,10 +131,20 @@ const AUTH_SCRIPT: &str = r#"
if (subtitle) subtitle.textContent = isSignUp ? '创建账号后进入你的工作区' : '登录后进入你的工作区';
submit.textContent = isSignUp ? '注册并登录' : '登录';
switcher.textContent = isSignUp ? '已有账号?登录' : '没有账号?注册';
if (accountLabel) accountLabel.textContent = isSignUp ? '邮箱' : '邮箱或用户名';
if (accountLabel) accountLabel.textContent = '邮箱或用户名';
if (accountField) accountField.hidden = isSignUp;
if (emailField) emailField.hidden = !isSignUp;
if (usernameField) usernameField.hidden = !isSignUp;
if (accountInput) {
accountInput.placeholder = isSignUp ? '请输入邮箱' : '请输入邮箱或用户名';
accountInput.autocomplete = isSignUp ? 'email' : 'username';
accountInput.required = !isSignUp;
accountInput.placeholder = '请输入邮箱或用户名';
accountInput.autocomplete = 'username';
}
if (emailInput) {
emailInput.required = isSignUp;
}
if (usernameInput) {
usernameInput.required = isSignUp;
}
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
@@ -115,69 +152,42 @@ const AUTH_SCRIPT: &str = r#"
setMessage('', '');
}
function accountMap() {
try {
return JSON.parse(window.localStorage.getItem('mnote.auth.accountEmailByName') || '{}') || {};
} catch (_) {
return {};
}
}
function rememberAccount(email, name) {
var normalizedEmail = String(email || '').trim();
var normalizedName = String(name || '').trim();
if (!normalizedEmail || !normalizedName || normalizedName.indexOf('@') !== -1) return;
try {
var map = accountMap();
map[normalizedName.toLowerCase()] = normalizedEmail;
window.localStorage.setItem('mnote.auth.accountEmailByName', JSON.stringify(map));
} catch (_) {}
}
function resolveAccountEmail(account) {
var normalized = String(account || '').trim();
if (normalized.indexOf('@') !== -1) return normalized;
var mapped = accountMap()[normalized.toLowerCase()];
return String(mapped || normalized).trim();
}
function defaultNameFromEmail(email) {
return String(email || '').split('@')[0].trim();
}
function buildPayload(flow, account, password) {
var email = flow === 'signIn' ? resolveAccountEmail(account) : String(account || '').trim();
var name = defaultNameFromEmail(email);
function buildPayload(flow, values) {
var accountValue = String(values.account || '').trim();
var emailValue = String(values.email || '').trim();
var usernameValue = String(values.username || '').trim();
var payload = {
action: 'auth:signIn',
args: {
provider: 'password',
params: {
email: email,
password: password,
password: values.password,
flow: flow
}
}
};
if (name) {
payload.args.params.name = name;
if (flow === 'signUp') {
payload.args.params.email = emailValue;
payload.args.params.name = usernameValue;
} else {
payload.args.params.account = accountValue;
if (accountValue.indexOf('@') !== -1) payload.args.params.email = accountValue;
if (accountValue) payload.args.params.name = accountValue.indexOf('@') === -1 ? accountValue : accountValue.split('@')[0];
}
return payload;
}
async function requestAuth(flow, account, password) {
async function requestAuth(flow, values) {
var response = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(buildPayload(flow, account, password))
body: JSON.stringify(buildPayload(flow, values))
});
var data = await response.json().catch(function () { return {}; });
if (!response.ok || data.error) {
throw new Error(data.error || '登录失败,请重试');
}
var params = buildPayload(flow, account, password).args.params;
rememberAccount(params.email, params.name);
return data;
}
@@ -188,20 +198,18 @@ const AUTH_SCRIPT: &str = r#"
form.addEventListener('submit', async function (event) {
event.preventDefault();
var account = String((accountInput || {}).value || '').trim();
var email = String((emailInput || {}).value || '').trim();
var username = String((usernameInput || {}).value || '').trim();
var password = String((root.querySelector('#password') || {}).value || '');
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
if (!account || !password) {
if ((flow === 'signIn' && !account) || (flow === 'signUp' && (!email || !username)) || !password) {
setMessage('请完整填写信息', 'error');
return;
}
if (flow === 'signUp' && account.indexOf('@') === -1) {
setMessage('注册时请填写邮箱;登录时可输入邮箱或已记住的用户名', 'error');
return;
}
setBusy(true);
setMessage(flow === 'signUp' ? '正在创建账号...' : '正在登录...', 'info');
try {
await requestAuth(flow, account, password);
await requestAuth(flow, { account: account, email: email, username: username, password: password });
setMessage('登录成功,正在进入工作区...', 'success');
window.location.assign('/');
} catch (error) {
@@ -226,8 +234,7 @@ const AUTH_SCRIPT: &str = r#"
setBusy(true);
setMessage('正在登录测试账号...', 'info');
try {
await requestAuth('signIn', email, password);
rememberAccount(email, username);
await requestAuth('signIn', { account: email, password: password });
setMessage('登录成功,正在进入工作区...', 'success');
window.location.assign('/');
return;
@@ -236,8 +243,7 @@ const AUTH_SCRIPT: &str = r#"
}
try {
await requestAuth('signUp', email, password);
rememberAccount(email, username);
await requestAuth('signUp', { email: email, username: username, password: password });
setMessage('测试账号已创建,正在进入工作区...', 'success');
window.location.assign('/');
} catch (error) {
+100 -10
View File
@@ -1222,6 +1222,11 @@ const SIDEBAR_TREE_JS: &str = r##"
});
}
function closeProfileDialog() {
var existing = document.querySelector('[data-testid="mnote-profile-dialog"]');
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
}
function accountMenuFallbackSession() {
var body = document.body instanceof HTMLElement ? document.body : null;
var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
@@ -1242,8 +1247,24 @@ const SIDEBAR_TREE_JS: &str = r##"
var typeNode = menu.querySelector('[data-account-info="actor-type"]');
if (nameNode) nameNode.textContent = session.name || session.userId || '';
if (emailNode) emailNode.textContent = session.email || '';
if (idNode) idNode.textContent = session.userId || 'anonymous';
if (idNode) {
idNode.textContent = session.userId || 'anonymous';
idNode.setAttribute('title', session.userId || 'anonymous');
}
if (typeNode) typeNode.textContent = session.actorType || session.authMode || 'unknown';
var copyButton = menu.querySelector('[data-account-copy-user-id]');
if (copyButton instanceof HTMLElement) {
copyButton.dataset.copyValue = session.userId || 'anonymous';
copyButton.setAttribute('title', ' ID');
}
}
function sessionIsAdmin(session) {
return String(session && session.actorType || '').trim() === 'admin';
}
function accessPolicyHrefForSession(session) {
return sessionIsAdmin(session) ? '/admin/access-policy' : '/user/access-policy';
}
async function loadAccountInfo(menu) {
@@ -1261,6 +1282,70 @@ const SIDEBAR_TREE_JS: &str = r##"
}
}
async function fetchAccountSession() {
try {
var response = await fetch('/api/auth/session', {
method: 'GET',
headers: { 'accept': 'application/json' },
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload) throw new Error('session_failed_' + response.status);
return payload;
} catch (_) {
return accountMenuFallbackSession();
}
}
function openProfileDialog(session) {
closeProfileDialog();
closeAccountMenu();
var dialog = document.createElement('div');
dialog.className = 'mnote-profile-dialog';
dialog.setAttribute('data-testid', 'mnote-profile-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'mnote-profile-dialog-title');
var userId = escapeHtml(session.userId || 'anonymous');
dialog.innerHTML =
'<div class="mnote-profile-dialog__backdrop" data-profile-dialog-close></div>' +
'<section class="mnote-profile-dialog__panel">' +
'<header class="mnote-profile-dialog__header">' +
'<div>' +
'<div class="mnote-profile-dialog__eyebrow"></div>' +
'<h2 id="mnote-profile-dialog-title"></h2>' +
'</div>' +
'<button type="button" class="mnote-profile-dialog__close" data-profile-dialog-close aria-label="关闭"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'</header>' +
'<div class="mnote-profile-dialog__identity">' +
'<div class="mnote-profile-dialog__avatar">' + escapeHtml(String(session.name || session.email || session.userId || '用').slice(0, 1).toUpperCase()) + '</div>' +
'<div><strong>' + escapeHtml(session.name || session.userId || '') + '</strong><span>' + escapeHtml(session.email || '') + '</span></div>' +
'</div>' +
'<dl class="mnote-profile-dialog__list">' +
'<div><dt></dt><dd>' + escapeHtml(session.name || '') + '</dd></div>' +
'<div><dt></dt><dd>' + escapeHtml(session.email || '') + '</dd></div>' +
'<div><dt> ID</dt><dd><code title="' + userId + '">' + userId + '</code><button type="button" data-profile-copy-user-id data-copy-value="' + userId + '"><span class="material-symbols-outlined" data-icon="content_copy" aria-hidden="true"></span></button></dd></div>' +
'<div><dt></dt><dd>' + escapeHtml(session.actorType || session.authMode || 'unknown') + '</dd></div>' +
'</dl>' +
'</section>';
dialog.querySelectorAll('[data-profile-dialog-close]').forEach(function(button) {
button.addEventListener('click', function(event) {
event.preventDefault();
closeProfileDialog();
});
});
var copyButton = dialog.querySelector('[data-profile-copy-user-id]');
if (copyButton) {
copyButton.addEventListener('click', function(event) {
event.preventDefault();
void copyTreeContextValue(copyButton.getAttribute('data-copy-value') || '', 'profile-user-id');
});
}
document.body.appendChild(dialog);
var closeButton = dialog.querySelector('.mnote-profile-dialog__close');
if (closeButton instanceof HTMLElement) closeButton.focus();
}
async function signOutAccount(trigger) {
setCommandPending(trigger, true);
try {
@@ -1296,16 +1381,22 @@ const SIDEBAR_TREE_JS: &str = r##"
menu.setAttribute('data-testid', 'mnote-account-menu');
menu.setAttribute('role', 'menu');
menu.innerHTML =
'<div class="mnote-account-menu__profile" role="group" aria-label="个人信息">' +
'<div class="mnote-account-menu__title"></div>' +
'<div class="mnote-account-menu__name" data-account-info="name">...</div>' +
'<div class="mnote-account-menu__row"><span></span><strong data-account-info="email">...</strong></div>' +
'<div class="mnote-account-menu__row"><span> ID</span><strong data-account-info="user-id">...</strong></div>' +
'<div class="mnote-account-menu__row"><span></span><strong data-account-info="actor-type">...</strong></div>' +
'</div>' +
'<div class="mnote-account-menu__separator" aria-hidden="true"></div>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span></span></button>' +
'<a class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" href="/user/access-policy"><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span></span></a>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退</button>' +
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
var sessionPromise = fetchAccountSession();
var profileButton = menu.querySelector('[data-testid="mnote-account-profile"]');
if (profileButton) {
profileButton.addEventListener('click', function(event) {
event.preventDefault();
sessionPromise.then(openProfileDialog);
});
}
var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]');
sessionPromise.then(function(session) {
if (accessPolicyLink instanceof HTMLElement) accessPolicyLink.setAttribute('href', accessPolicyHrefForSession(session));
});
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
if (signOutButton) {
signOutButton.addEventListener('click', function(event) {
@@ -1315,7 +1406,6 @@ const SIDEBAR_TREE_JS: &str = r##"
}
var host = trigger.closest('[data-testid="wolai-sidebar-quick-actions"]') || trigger.parentElement;
if (host) host.appendChild(menu);
void loadAccountInfo(menu);
}
autoOpenRecentLocalRootOnHome();
+11 -209
View File
@@ -69,6 +69,10 @@ a:hover {
color: var(--wolai-accent-hover);
}
[hidden] {
display: none !important;
}
.mnote-symbol {
width: 18px;
height: 18px;
@@ -189,6 +193,9 @@ a:hover {
.material-symbols-outlined[data-icon="edit"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 5v14M5 12h14' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="subdirectory_arrow_right"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4v7a3 3 0 0 0 3 3h8M14 10l4 4-4 4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="account_circle"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='12' cy='9' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M6.8 19a5.4 5.4 0 0 1 10.4 0' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="admin_panel_settings"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3 5 6v5c0 4.5 2.9 8 7 10 4.1-2 7-5.5 7-10V6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m9 12 2 2 4-5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="close"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6 18 18M18 6 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="radio_button_unchecked"]::before {
width: .78em;
height: .78em;
@@ -338,73 +345,7 @@ a:hover {
color: #1D4ED8;
}
.mnote-account-menu {
inset-inline-start: auto;
right: 8px;
top: calc(100% - 10px);
width: 232px;
}
.mnote-account-menu__profile {
padding: 8px;
}
.mnote-account-menu__title {
color: var(--wolai-text-secondary);
font-size: 12px;
line-height: 1.35;
}
.mnote-account-menu__name {
margin-top: 5px;
color: var(--wolai-text-primary);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-account-menu__row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
margin-top: 6px;
color: var(--wolai-text-secondary);
font-size: 12px;
line-height: 1.35;
}
.mnote-account-menu__row strong {
min-width: 0;
color: var(--wolai-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-account-menu__separator {
height: 1px;
margin: 4px 0;
background: var(--wolai-border);
}
.mnote-account-menu__logout {
color: #C2410C;
}
.mnote-account-menu__logout:hover {
background: #FFF7ED;
}
.mnote-account-menu__error {
padding: 6px 8px 2px;
color: #B91C1C;
font-size: 12px;
line-height: 1.35;
}
.mnote-account-menu{inset-inline-start:auto;right:8px;top:calc(100% - 10px);width:194px}.mnote-account-menu__item{display:flex;align-items:center;gap:10px}.mnote-account-menu__logout{color:#C2410C}.mnote-account-menu__logout:hover{background:#FFF7ED}.mnote-account-menu__error{padding:6px 8px 2px;color:#B91C1C;font-size:12px}.mnote-profile-dialog{position:fixed;inset:0;z-index:1200;display:grid;place-items:center}.mnote-profile-dialog__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.22)}.mnote-profile-dialog__panel{position:relative;width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--wolai-border);border-radius:8px;background:#fff;box-shadow:0 18px 54px rgba(15,23,42,.18);padding:20px}.mnote-profile-dialog__header,.mnote-profile-dialog__identity,.mnote-profile-dialog__list>div,.mnote-profile-dialog__list dd{display:flex;align-items:center}.mnote-profile-dialog__header{justify-content:space-between;margin-bottom:18px}.mnote-profile-dialog__eyebrow,.mnote-profile-dialog__identity span,.mnote-profile-dialog__list dt{color:var(--wolai-text-secondary);font-size:13px}.mnote-profile-dialog__header h2{font-size:20px}.mnote-profile-dialog__close,.mnote-profile-dialog__list button{border:0;border-radius:6px;background:transparent;color:var(--wolai-text-secondary);cursor:pointer}.mnote-profile-dialog__close{width:32px;height:32px}.mnote-profile-dialog__identity{gap:12px;padding:12px;border-radius:8px;background:var(--wolai-bg-sidebar)}.mnote-profile-dialog__avatar{width:36px;height:36px;display:grid;place-items:center;border-radius:6px;background:#D6545D;color:#fff;font-weight:650}.mnote-profile-dialog__list{margin-top:16px}.mnote-profile-dialog__list>div{justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--wolai-border)}.mnote-profile-dialog__list dd{min-width:0;gap:8px;max-width:280px;font-size:13px;text-align:right;overflow-wrap:anywhere}.mnote-profile-dialog__list code{font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px;white-space:normal}
.wolai-quick-actions {
flex: 0 0 auto;
@@ -1959,146 +1900,7 @@ body {
background: var(--atelier-document);
}
.mnote-admin-policy-page {
width: min(1080px, calc(100vw - 64px));
margin: 40px auto 64px;
color: var(--atelier-text);
}
.mnote-admin-policy-header {
margin-bottom: 24px;
}
.mnote-admin-policy-header h1 {
font-size: 28px;
font-weight: 650;
margin-bottom: 8px;
}
.mnote-admin-policy-header p,
.mnote-admin-policy-note {
color: var(--wolai-text-secondary);
font-size: 14px;
}
.mnote-admin-policy-summary,
.mnote-admin-policy-panel,
.mnote-admin-policy-form {
border: 1px solid var(--wolai-border);
border-radius: 8px;
background: #fff;
}
.mnote-admin-policy-summary {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(280px, 2fr);
gap: 16px;
padding: 16px;
margin-bottom: 16px;
}
.mnote-admin-policy-summary-item {
min-width: 0;
}
.mnote-admin-policy-summary-label {
display: block;
margin-bottom: 6px;
color: var(--wolai-text-secondary);
font-size: 12px;
}
.mnote-admin-policy-summary code,
.mnote-admin-policy-json {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
.mnote-admin-policy-panel {
padding: 16px;
margin-bottom: 16px;
}
.mnote-admin-policy-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.mnote-admin-policy-panel h2,
.mnote-admin-policy-form h2 {
font-size: 16px;
font-weight: 650;
}
.mnote-admin-policy-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.mnote-admin-policy-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.mnote-admin-policy-form label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--wolai-text-secondary);
font-size: 12px;
}
.mnote-admin-policy-form input,
.mnote-admin-policy-form select {
min-height: 34px;
border: 1px solid var(--wolai-border);
border-radius: 6px;
padding: 6px 8px;
color: var(--wolai-text-primary);
background: #fff;
font-size: 13px;
}
.mnote-admin-policy-form button,
.mnote-admin-policy-panel button {
min-height: 34px;
border: 1px solid var(--wolai-border);
border-radius: 6px;
padding: 6px 12px;
color: var(--wolai-text-primary);
background: var(--wolai-bg-sidebar);
cursor: pointer;
}
.mnote-admin-policy-form button:hover,
.mnote-admin-policy-panel button:hover {
background: var(--wolai-bg-hover);
}
.mnote-admin-policy-json {
min-height: 48px;
max-height: 320px;
overflow: auto;
border-radius: 6px;
background: var(--wolai-bg-sidebar);
padding: 10px;
color: var(--wolai-text-primary);
}
@media (max-width: 960px) {
.mnote-admin-policy-summary,
.mnote-admin-policy-grid {
grid-template-columns: 1fr;
}
}
.mnote-admin-policy-page{width:min(1080px,calc(100vw - 64px));margin:40px auto 64px}.mnote-admin-policy-header{margin-bottom:24px}.mnote-admin-policy-header h1{font-size:28px;font-weight:650}.mnote-admin-policy-header p,.mnote-admin-policy-note{color:var(--wolai-text-secondary);font-size:14px}.mnote-admin-policy-summary,.mnote-admin-policy-panel,.mnote-admin-policy-form{border:1px solid var(--wolai-border);border-radius:8px;background:#fff}.mnote-admin-policy-summary{display:grid;grid-template-columns:1fr 2fr;gap:16px;padding:16px;margin-bottom:16px}.mnote-admin-policy-summary-label{display:block;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-summary code,.mnote-admin-policy-json{white-space:pre-wrap;overflow-wrap:anywhere;font-family:"JetBrains Mono","SFMono-Regular",Consolas,monospace;font-size:12px}.mnote-admin-policy-panel,.mnote-admin-policy-form{padding:16px;margin-bottom:16px}.mnote-admin-policy-panel-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:12px}.mnote-admin-policy-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.mnote-admin-policy-form{display:flex;flex-direction:column;gap:12px}.mnote-admin-policy-form label{display:flex;flex-direction:column;gap:6px;color:var(--wolai-text-secondary);font-size:12px}.mnote-admin-policy-form input,.mnote-admin-policy-form select{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 8px;background:#fff}.mnote-admin-policy-form button,.mnote-admin-policy-panel button{min-height:34px;border:1px solid var(--wolai-border);border-radius:6px;padding:6px 12px;background:var(--wolai-bg-sidebar);cursor:pointer}.mnote-admin-policy-json{min-height:48px;max-height:320px;overflow:auto;border-radius:6px;background:var(--wolai-bg-sidebar);padding:10px}.mnote-admin-policy-debug{margin-top:12px}.mnote-admin-policy-table{display:grid;gap:8px}.mnote-admin-policy-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(120px,.6fr) minmax(0,1fr);gap:12px;border:1px solid var(--wolai-border);border-radius:8px;padding:12px}.mnote-admin-policy-row strong,.mnote-admin-policy-row span{display:block;min-width:0}.mnote-admin-policy-row span{color:var(--wolai-text-secondary);font-size:12px;overflow-wrap:anywhere}.mnote-admin-policy-badge{display:inline-flex!important;margin:2px 4px 2px 0;border-radius:999px;padding:2px 8px;background:var(--wolai-bg-sidebar);font-size:12px}.mnote-admin-policy-badge[data-value="write"]{background:#DCFCE7}.mnote-admin-policy-badge[data-value="read"]{background:#DBEAFE}.mnote-admin-policy-badge[data-value="已撤销"]{background:#FEE2E2}.mnote-admin-policy-empty{border:1px dashed var(--wolai-border);border-radius:8px;padding:18px;color:var(--wolai-text-secondary);background:var(--wolai-bg-sidebar);font-size:13px}@media(max-width:960px){.mnote-admin-policy-summary,.mnote-admin-policy-grid,.mnote-admin-policy-row{grid-template-columns:1fr}}
.mnote-trash-workbench {
width: min(860px, calc(100vw - 64px));
@@ -4393,7 +4195,7 @@ mod tests {
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI 和双 pane 布局,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 100000);
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 110000);
}
}
@@ -0,0 +1,130 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const {
findFreePort,
waitForGateway,
} = require("./task114-rust-web-gateway-entry-smoke.js");
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
function chromiumExecutablePath() {
return [
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => candidate && fs.existsSync(candidate)) || undefined;
}
function startGateway(port) {
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env: {
...process.env,
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
},
stdio: ["ignore", "pipe", "pipe"],
});
}
async function main() {
let baseUrl = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
let gateway = null;
if (!baseUrl) {
const port = await findFreePort();
baseUrl = `http://127.0.0.1:${port}`;
gateway = startGateway(port);
await waitForGateway(baseUrl);
}
const executablePath = chromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
try {
const authPage = await browser.newPage();
await authPage.goto(`${baseUrl}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await authPage.locator('[data-testid="mnote-auth-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(await authPage.locator('#account').isVisible(), "登录态应显示邮箱或用户名输入框");
assert(!(await authPage.locator('#email').isVisible()), "登录态不应显示注册邮箱输入框");
await authPage.locator('[data-auth-switch]').click();
await authPage.locator('#email').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(await authPage.locator('#username').isVisible(), "注册态应显示用户名输入框");
assert(!(await authPage.locator('#account').isVisible()), "注册态不应显示登录账号输入框");
await authPage.close();
const userContext = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "user_profile_smoke",
"x-mnote-actor-type": "user",
},
});
const userPage = await userContext.newPage();
await userPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
await userPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await userPage.waitForFunction(() => {
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
return link && link.getAttribute("href") === "/user/access-policy";
}, { timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-account-profile"]').click();
await userPage.locator('[data-testid="mnote-profile-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const profileText = await userPage.locator('[data-testid="mnote-profile-dialog"]').innerText();
assert(profileText.includes("user_profile_smoke"), "个人信息弹窗应显示完整用户 ID");
await userPage.goto(`${baseUrl}/user/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const userAccessText = await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
assert(userAccessText.includes("分享管理"), "普通用户授权页应显示分享管理");
assert(!userAccessText.includes("验证目录"), "普通用户授权页不应显示目录授权验证表单");
await userContext.close();
const adminContext = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "admin_profile_smoke",
"x-mnote-actor-type": "admin",
},
});
const adminPage = await adminContext.newPage();
await adminPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await adminPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
await adminPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await adminPage.waitForFunction(() => {
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
return link && link.getAttribute("href") === "/admin/access-policy";
}, { timeout: UI_TIMEOUT_MS });
await adminPage.goto(`${baseUrl}/admin/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const adminAccessText = await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
assert(adminAccessText.includes("目录授权"), "管理员授权页应显示目录授权");
assert(adminAccessText.includes("分享管理"), "管理员授权页应显示分享管理");
assert(await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').isVisible(), "管理员页应显示验证目录按钮");
await adminContext.close();
console.log(JSON.stringify({ ok: true, task: "task489-auth-profile-access-ui", baseUrl }, null, 2));
} finally {
await browser.close().catch(() => {});
if (gateway) {
gateway.kill("SIGTERM");
setTimeout(() => gateway.kill("SIGKILL"), 2_000).unref();
}
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});