From 531e8456008cf19c9c48d2e45f7c63309058de20 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Fri, 22 May 2026 01:47:40 +0800 Subject: [PATCH] fix auth registration and access management ui --- convex/_generated/api.d.ts | 4 + convex/auth.config.ts | 8 + convex/auth.ts | 19 + convex/schema.ts | 3 + convex/users.ts | 77 ++++ ...-7-auth-profile-access-management-ui-v1.md | 31 ++ package.json | 5 +- pnpm-lock.yaml | 165 ++++++++ rust/crates/mnote-web/src/routes/gateway.rs | 326 ++++++++++++++- .../src/routes/local_folder_source.rs | 113 ++++- rust/crates/mnote-web/src/routes/mod.rs | 13 + rust/crates/mnote-web/src/ssr/pages/admin.rs | 386 ++++++++++++------ rust/crates/mnote-web/src/ssr/pages/auth.rs | 116 +++--- rust/crates/mnote-web/src/ssr/pages/layout.rs | 110 ++++- rust/crates/mnote-web/src/ssr/styles.rs | 220 +--------- .../task489-auth-profile-access-ui-smoke.js | 130 ++++++ 16 files changed, 1316 insertions(+), 410 deletions(-) create mode 100644 convex/auth.config.ts create mode 100644 convex/auth.ts create mode 100644 convex/users.ts create mode 100644 design/02-convex-rust-long-term-architecture/process/2-7-auth-profile-access-management-ui-v1.md create mode 100644 scripts/task489-auth-profile-access-ui-smoke.js 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! { - +
-

"目录授权"

-

"管理员可以查看、验证和管理本地目录授权,普通用户不会看到入口。"

+

{page_title.clone()}

+

{if is_admin { + "管理员可以管理目录授权和分享授权。" + } else { + "查看与你相关的分享授权。" + }}

@@ -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() + }}
-

"分享授权"

+
+

"分享管理"

+

{if is_admin { + "查看、创建和撤销分享授权。" + } else { + "查看你创建或接收的分享授权。" + }}

+
-
{""}
+
+
"正在读取分享授权..."
+
+
+ "查看分享授权 JSON" +
{""}
+
-
+

"新增分享授权"

+ {if is_admin { + view! { + + }.into_any() + } else { + view! { + + }.into_any() + }} -
+
@@ -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 { -