diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 29c6fda4..4dac351d 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -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;
}>;
/**
diff --git a/convex/auth.config.ts b/convex/auth.config.ts
new file mode 100644
index 00000000..f4eb5646
--- /dev/null
+++ b/convex/auth.config.ts
@@ -0,0 +1,8 @@
+export default {
+ providers: [
+ {
+ domain: process.env.CONVEX_SITE_URL,
+ applicationID: "convex",
+ },
+ ],
+};
diff --git a/convex/auth.ts b/convex/auth.ts
new file mode 100644
index 00000000..d50e479c
--- /dev/null
+++ b/convex/auth.ts
@@ -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 };
+ },
+ }),
+ ],
+});
diff --git a/convex/schema.ts b/convex/schema.ts
index 3eb7778b..afcaab84 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -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(),
diff --git a/convex/users.ts b/convex/users.ts
new file mode 100644
index 00000000..af733e53
--- /dev/null
+++ b/convex/users.ts
@@ -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 };
+ },
+});
diff --git a/design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md b/design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md
new file mode 100644
index 00000000..fa9a076d
--- /dev/null
+++ b/design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md
@@ -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 .`。
diff --git a/package.json b/package.json
index bb1d46c3..da5bf6b4 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 43051271..ce7449c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs
index c83f37c3..9e7bab6f 100644
--- a/rust/crates/mnote-web/src/routes/gateway.rs
+++ b/rust/crates/mnote-web/src/routes/gateway.rs
@@ -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! {
-
+
});
let mut response = Html(format!(
r#"
- 目录授权
+ 授权管理
@@ -214,6 +215,44 @@ pub async fn admin_access_policy_entry(
Ok(response)
}
+pub async fn user_access_policy_entry(
+ State(state): State,
+ Extension(context): Extension,
+) -> Result {
+ 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! {
+
+ });
+ let mut response = Html(format!(
+ r#"
+
+
+
+ 授权管理
+
+
+
+ {}
+
+"#,
+ 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,
Extension(context): Extension,
@@ -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 {
+ 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 {
+ 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::));
+ 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");
diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs
index 718e2d4e..f77962a9 100644
--- a/rust/crates/mnote-web/src/routes/local_folder_source.rs
+++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs
@@ -850,16 +850,40 @@ fn share_grant_payload(grant: &LocalShareGrant) -> Value {
})
}
-fn list_local_share_grants_for_context(context: &RequestContext) -> Result {
- require_share_grants_admin(context)?;
+fn list_local_share_grants_for_context(
+ context: &RequestContext,
+ admin_required: bool,
+) -> Result {
+ 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::>();
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 {
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 {
+ 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 {
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 {
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 {
+ 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 {
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,
) -> Result<(StatusCode, Json), 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,
+) -> Result<(StatusCode, Json), 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,
+ Json(request): Json,
+) -> Result<(StatusCode, Json), 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,
+ AxumPath(share_id): AxumPath,
+) -> Result<(StatusCode, Json), 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,
Json(request): Json,
diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs
index 2b09aaab..6df2d230 100644
--- a/rust/crates/mnote-web/src/routes/mod.rs
+++ b/rust/crates/mnote-web/src/routes/mod.rs
@@ -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),
diff --git a/rust/crates/mnote-web/src/ssr/pages/admin.rs b/rust/crates/mnote-web/src/ssr/pages/admin.rs
index 25dc788f..8d4145a9 100644
--- a/rust/crates/mnote-web/src/ssr/pages/admin.rs
+++ b/rust/crates/mnote-web/src/ssr/pages/admin.rs
@@ -8,6 +8,7 @@ pub fn AdminAccessPolicyPage(
#[prop(optional)] workspace_name: Option,
#[prop(optional)] policy_path: Option,
#[prop(optional)] share_grants_path: Option,
+ #[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! {
-
+
@@ -32,7 +38,7 @@ pub fn AdminAccessPolicyPage(
"策略文件"
- {policy_path.clone()}
+ {if is_admin { policy_path.clone() } else { "仅管理员可见".to_string() }}
"分享授权文件"
@@ -40,102 +46,149 @@ pub fn AdminAccessPolicyPage(
-
+ {if is_admin {
+ view! {
+
+
+
+
+ "查看策略 JSON"
+ {""}
+
+
+
+ }.into_any()
+ } else {
+ view! {
+
+ }.into_any()
+ }}
-
-
+ {if is_admin {
+ view! {
+
+
+
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
- {""}
+
+
+ "查看分享授权 JSON"
+ {""}
+
-
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function renderBadge(value) {
+ var text = String(value || '').trim() || 'read';
+ return '' + escapeHtml(text) + '';
+ }
+
+ function renderPolicyGrants(payload) {
+ if (!policyGrantsList) return;
+ var grants = payload && payload.policy && Array.isArray(payload.policy.grants) ? payload.policy.grants : [];
+ if (!grants.length) {
+ policyGrantsList.innerHTML = '暂无目录授权
';
+ return;
+ }
+ policyGrantsList.innerHTML = grants.map(function(grant) {
+ return '' +
+ '' + escapeHtml(grant.userId || grant.user_id || '未知用户') + '' + escapeHtml(grant.rootPath || grant.root_path || grant.rootUri || grant.root_uri || '') + '
' +
+ '' + renderBadge(grant.permission || grant.access) + '
' +
+ '' + escapeHtml((grant.capabilities || []).join(', ') || '无能力标记') + '
' +
+ '';
+ }).join('');
+ }
+
+ function renderShareGrants(payload) {
+ if (!shareGrantsList) return;
+ var grants = payload && Array.isArray(payload.grants) ? payload.grants : [];
+ if (!grants.length) {
+ shareGrantsList.innerHTML = '暂无分享授权
';
+ return;
+ }
+ shareGrantsList.innerHTML = grants.map(function(grant) {
+ var active = grant.active === false ? '已撤销' : '有效';
+ return '' +
+ '' + escapeHtml(grant.shareId || grant.id || '未命名分享') + '' + escapeHtml(grant.rootPath || grant.rootUri || '') + '
' +
+ '' + renderBadge(grant.permission) + renderBadge(active) + '
' +
+ '所有者 ' + escapeHtml(grant.ownerUserId || '') + '接收者 ' + escapeHtml(grant.targetUserId || '') + '
' +
+ '';
+ }).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);
});
})();
"#;
diff --git a/rust/crates/mnote-web/src/ssr/pages/auth.rs b/rust/crates/mnote-web/src/ssr/pages/auth.rs
index 1f53346a..db329d09 100644
--- a/rust/crates/mnote-web/src/ssr/pages/auth.rs
+++ b/rust/crates/mnote-web/src/ssr/pages/auth.rs
@@ -24,7 +24,7 @@ pub fn AuthPage() -> impl IntoView {
-