fix auth registration and access management ui
This commit is contained in:
Vendored
+4
@@ -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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
+31
@@ -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
@@ -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",
|
||||
|
||||
Generated
+165
@@ -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: {}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user