Compare commits

..

No commits in common. "main" and "v2026.07.08" have entirely different histories.

23 changed files with 779 additions and 576 deletions

View File

@ -41,11 +41,7 @@ npm run build # 生产构建
| `src/app/login/page.tsx` | 登录页LLDAP 认证) |
| `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) |
| `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) |
| `src/app/api/auth/login/route.ts` | 登录 APILDAP 认证 + 审计日志 + 跨站点角色同步) |
| `src/app/api/auth/callback/route.ts` | OIDC callbackhandleOidcCallback + 跨站点角色同步) |
| `src/lib/db.ts` | **新增** SQLite 数据库(审计日志专用) |
| `src/lib/audit.ts` | **新增** 审计日志写入封装 |
| `src/lib/sync-user.ts` | **新增** 跨站点用户角色同步函数syncUserToAllSites |
| `src/app/api/auth/login/route.ts` | 登录 APIOA 仅 LLDAP 认证,无本地 DB |
| `src/app/api/auth/logout/route.ts` | 退出 API清除 tlyq_session |
| `src/app/api/auth/change-password/route.ts` | 修改密码docker exec 调 lldap_set_password |
| `src/app/api/admin/create-user/route.ts` | 创建用户SQLite 写 LLDAP + 自动同步站点 + 角色设置) |
@ -87,11 +83,6 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
| `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 |
| `OIDC_REDIRECT_URI` | `http://localhost:6179/api/auth/callback` | `https://oa.tlyq.ai/api/auth/callback` |
| `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`Authelia 使用自签名证书) |
| `INTERNAL_API_KEY` | 各站点相同值 | 由 deploy-ai.sh 自动生成并注入(所有站点共用) |
| `MONITOR_INTERNAL_URL` | `http://localhost:6181` | `http://monitor-ai:3000`(跨站点同步目标) |
| `ASSETS_INTERNAL_URL` | `http://localhost:6177` | `http://assets-ai:3000`(跨站点同步目标) |
| `ISSUE_INTERNAL_URL` | `http://localhost:6176` | `http://issue-ai:3000`(跨站点同步目标) |
| `DATABASE_PATH` | `./data/oa.db` | `/app/data/oa.db`(审计日志 SQLite |
### `.env` 示例
@ -107,11 +98,6 @@ AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=oa-oidc
OIDC_CLIENT_SECRET=<本地生成的哈希值>
OIDC_REDIRECT_URI=http://localhost:6179/api/auth/callback
DATABASE_PATH=./data/oa.db
INTERNAL_API_KEY=dev-internal-key-change-in-production
MONITOR_INTERNAL_URL=http://localhost:6181
ASSETS_INTERNAL_URL=http://localhost:6177
ISSUE_INTERNAL_URL=http://localhost:6176
```
---

445
package-lock.json generated
View File

@ -9,7 +9,6 @@
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"ldapts": "^6.0.0",
"next": "^15.0.0",
"openid-client": "^5.7.1",
@ -20,7 +19,6 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"autoprefixer": "^10.5.2",
@ -1076,16 +1074,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz",
@ -1157,26 +1145,6 @@
"postcss": "^8.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@ -1199,40 +1167,6 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@ -1267,30 +1201,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@ -1311,12 +1221,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -1347,34 +1251,11 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -1387,15 +1268,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
@ -1420,27 +1292,12 @@
"node": ">=6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -1455,18 +1312,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1474,38 +1319,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@ -1838,33 +1651,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1889,12 +1675,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.18",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz",
@ -1975,18 +1755,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@ -2015,15 +1783,6 @@
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@ -2087,58 +1846,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@ -2160,20 +1867,6 @@
"react": "^19.2.6"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resend": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
@ -2195,26 +1888,6 @@
}
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -2232,6 +1905,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@ -2284,51 +1958,6 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2354,24 +1983,6 @@
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@ -2425,52 +2036,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2522,12 +2093,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@ -2542,12 +2107,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -9,7 +9,6 @@
},
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"ldapts": "^6.0.0",
"next": "^15.0.0",
"openid-client": "^5.7.1",
@ -20,7 +19,6 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"autoprefixer": "^10.5.2",

View File

@ -0,0 +1,151 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
import { sendSetupLinkEmail } from '@/lib/email'
import { signSetupToken } from '@/lib/setup-token'
const execAsync = promisify(exec)
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
function generatePassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
const lower = 'abcdefghjkmnpqrstuvwxyz'
const digits = '23456789'
const special = '!@#$%&*'
const all = upper + lower + digits + special
const crypto = globalThis.crypto
const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length]
// 确保每种类型至少一个,其余随机填充到 12 位
let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special)
for (let i = 4; i < 12; i++) pwd += pick(all)
// 打乱顺序
return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('')
}
async function fetchRoles(siteUrl: string): Promise<string[]> {
try {
const res = await fetch(`${siteUrl}/api/internal/roles`, {
headers: { 'x-internal-key': INTERNAL_KEY },
signal: AbortSignal.timeout(5000),
})
const data = await res.json()
return (data.roles || []).map((r: { name: string }) => r.name)
} catch {
return []
}
}
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
try {
const res = await fetch(`${siteUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(10000),
})
return res.ok
} catch {
return false
}
}
// 直接更新站点 SQLite 数据库中的用户角色
function setRoleSQL(dbPath: string, username: string, role: string): string {
return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"`
}
export async function POST(request: Request) {
try {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const session = verifySharedJwt(token)
if (!session || !(await isLldapAdmin(session.username))) {
return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 })
}
const { username, displayName, assetsRole, issueRole, email } = await request.json()
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 })
const password = generatePassword()
// 从各站点实时获取可用角色列表
const [assetsRoles, issueRoles] = await Promise.all([
fetchRoles('http://localhost:6177'),
fetchRoles('http://localhost:6176'),
])
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer'
const safeName = (displayName || username).replace(/'/g, "'\\''")
const safeUser = username.replace(/'/g, "'\\''")
const lldapEmail = email || ''
const d = new Date()
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
const userUuid = crypto.randomUUID()
// 1. LLDAP SQLite 插入用户
const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');`
await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 })
// 2. 从 LLDAP 容器动态获取 admin 密码不硬编码admin 改密码后无需改 OA 配置)
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
// 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题
const b64Pass = Buffer.from(password).toString('base64')
await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 })
const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'`
await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 })
// 3. 自动登录各站点触发用户同步
const [assetsOk, issueOk] = await Promise.all([
syncToSite('http://localhost:6177', username, password),
syncToSite('http://localhost:6176', username, password),
])
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值)
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
const roleResults = { assets: false, issue: false }
if (assetsOk) {
try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {}
}
if (issueOk) {
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
}
// 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码)
let emailSent = false
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
try {
const setupToken = signSetupToken(username)
const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}`
await sendSetupLinkEmail(email, username, setupUrl, displayName || username)
emailSent = true
} catch (e) {
console.error('发送邮件失败:', e)
}
}
return NextResponse.json({
success: true,
password: emailSent ? undefined : password,
synced: { assets: assetsOk, issue: issueOk },
roles: { assets: ar, issue: ir, applied: roleResults },
emailSent,
message: emailSent
? `用户已创建,密码设置链接已发送至 ${email}`
: '用户已创建并同步至所有站点',
})
} catch (e) {
const msg = e instanceof Error ? e.message : '创建失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> {
try {
const res = await fetch(`${url}/api/internal/roles`, {
headers: { 'x-internal-key': INTERNAL_KEY },
signal: AbortSignal.timeout(5000),
})
const data = await res.json()
return data.roles || []
} catch { return [] }
}
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const session = verifySharedJwt(token)
if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const [assetsRoles, issueRoles] = await Promise.all([
fetchRoles('http://localhost:6177'),
fetchRoles('http://localhost:6176'),
])
return NextResponse.json({ assets: assetsRoles, issue: issueRoles })
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
const execAsync = promisify(exec)
const ASSETS_DB = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
export async function POST() {
try {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const session = verifySharedJwt(token)
if (!session || !(await isLldapAdmin(session.username))) {
return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 })
}
const { stdout } = await execAsync(
`docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`,
{ timeout: 5000 }
)
const lines = stdout.trim().split('\n').filter(Boolean)
let synced = 0
for (const line of lines) {
const [user, mail] = line.split('|')
const su = user.replace(/'/g, "''")
const sm = (mail || '').replace(/'/g, "''")
for (const db of [ASSETS_DB, ISSUE_DB]) {
try {
await execAsync(
`sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`,
{ timeout: 3000 }
)
} catch {}
}
synced++
}
return NextResponse.json({ success: true, synced })
} catch (e) {
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,104 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
const execAsync = promisify(exec)
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
async function fetchRoles(siteUrl: string): Promise<string[]> {
try {
const res = await fetch(`${siteUrl}/api/internal/roles`, {
headers: { 'x-internal-key': INTERNAL_KEY },
signal: AbortSignal.timeout(5000),
})
const data = await res.json()
return (data.roles || []).map((r: { name: string }) => r.name)
} catch { return [] }
}
function queryDb(dbPath: string, sql: string): Promise<string> {
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '')
}
async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> {
const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
return out.trim().split('\n').filter(Boolean).map(line => {
const [username, display_name, role] = line.split('|')
return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' }
})
}
async function checkAdmin() {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return false
const session = verifySharedJwt(token)
return session ? isLldapAdmin(session.username) : false
}
// GET — 列出各站点用户及其角色
export async function GET() {
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const [assetsRoles, issueRoles] = await Promise.all([
fetchRoles('http://localhost:6177'),
fetchRoles('http://localhost:6176'),
])
const [assetsUsers, issueUsers] = await Promise.all([
getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles),
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles),
])
// 从 LLDAP 获取所有用户邮箱
let emails: Record<string, string> = {}
try {
const { stdout } = await execAsync(
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`,
{ timeout: 3000 }
)
stdout.trim().split('\n').filter(Boolean).forEach(line => {
const [uid, e] = line.split('|')
emails[uid] = e || ''
})
} catch {}
return NextResponse.json({
assetsRoles,
issueRoles,
users: { assets: assetsUsers, issue: issueUsers },
emails,
})
} catch (e) {
return NextResponse.json({ error: '查询失败' }, { status: 500 })
}
}
// PUT — 更新用户角色
export async function PUT(request: Request) {
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { username, site, role } = await request.json()
if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 })
const dbPath = site === 'assets'
? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db')
: (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db')
// 验证角色合法性
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 })
return NextResponse.json({ success: true })
} catch (e) {
return NextResponse.json({ error: '更新失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
const execAsync = promisify(exec)
function checkAdmin() {
return async () => {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return false
const session = verifySharedJwt(token)
return session ? isLldapAdmin(session.username) : false
}
}
// GET — 列出 LLDAP 中所有用户
export async function GET() {
const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { stdout } = await execAsync(
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`,
{ timeout: 5000 }
)
const users = stdout.trim().split('\n').filter(Boolean).map(line => {
const [user_id, email, display_name, creation_date] = line.split('|')
return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date }
})
return NextResponse.json({ users })
} catch (e) {
return NextResponse.json({ error: '查询失败' }, { status: 500 })
}
}
// DELETE — 删除用户LLDAP + 各站点)
export async function DELETE(request: Request) {
const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { username } = await request.json()
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
if (username === 'admin' || username === 'localadmin') {
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
}
const safeUser = username.replace(/'/g, "''")
// 删除 LLDAP 用户
const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';`
await execAsync(
`docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`,
{ timeout: 5000 }
)
// 删除各站点本地用户
const results: Record<string, boolean> = {}
for (const [site, dbPath] of Object.entries({
assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db',
issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db',
})) {
try {
await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 })
results[site] = true
} catch { results[site] = false }
}
return NextResponse.json({ success: true, deleted: results })
} catch (e) {
return NextResponse.json({ error: '删除失败' }, { status: 500 })
}
}
// PATCH — 修改用户信息admin 权限)
export async function PATCH(request: Request) {
const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { username, email, displayName } = await request.json()
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
if (email === undefined && displayName === undefined) {
return NextResponse.json({ error: '至少需要 email 或 displayName' }, { status: 400 })
}
if (email !== undefined && email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
}
const safeUser = username.replace(/'/g, "''")
const d = new Date()
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
// 更新 LLDAP
let lldapSets: string[] = []
let siteSets: string[] = []
if (email !== undefined) {
const safeEmail = (email || '').replace(/'/g, "''")
lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`)
siteSets.push(`email = '${safeEmail}'`)
}
if (displayName !== undefined) {
const safeName = displayName.replace(/'/g, "''")
lldapSets.push(`display_name = '${safeName}'`)
siteSets.push(`display_name = '${safeName}'`)
}
lldapSets.push(`modified_date = '${now}'`)
siteSets.push(`updated_at = datetime('now', '+8 hours')`)
const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';`
await execAsync(
`docker exec lldap /bin/sh -c "cat > /tmp/up.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/up.sql"`,
{ timeout: 5000 }
)
// 同步更新 assets / issue
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
const siteSQL = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}';`
for (const dbPath of [assetsDb, issueDb]) {
try { await execAsync(`sqlite3 "${dbPath}" "${siteSQL}"`, { timeout: 3000 }) } catch {}
}
return NextResponse.json({ success: true, username, email, displayName })
} catch (e) {
return NextResponse.json({ error: '修改失败' }, { status: 500 })
}
}

View File

@ -2,7 +2,6 @@
import { NextRequest } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { ldapUserExists } from '@/lib/ldap'
import { syncUserToAllSites } from '@/lib/sync-user'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
@ -12,7 +11,7 @@ const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: NextRequest) {
const response = await handleOidcCallback(request, {
return handleOidcCallback(request, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
@ -20,16 +19,9 @@ export async function GET(request: NextRequest) {
// 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正)
getUser: async (username) => {
const exists = await ldapUserExists(username)
if (exists) {
// 跨站点角色同步fire-and-forget不阻塞 callback 响应)
syncUserToAllSites(username, username, 'admin').catch(() => {})
return { id: -1, role: 'admin' }
}
return null
return exists ? { id: -1, role: 'admin' } : null
},
// OA 不通过 OIDC 创建/更新用户
})
return response
}

View File

@ -0,0 +1,27 @@
// GET /api/auth/callback — OIDC callbackV2OA 签发 tlyq_session
import { NextRequest } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { ldapUserExists } from '@/lib/ldap'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
export async function GET(request: NextRequest) {
return handleOidcCallback(request, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
// 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正)
getUser: async (username) => {
const exists = await ldapUserExists(username)
return exists ? { id: -1, role: 'admin' } : null
},
// OA 不通过 OIDC 创建/更新用户
})
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
const execAsync = promisify(exec)
export async function POST(request: Request) {
try {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const session = verifySharedJwt(token)
if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const { currentPassword, newPassword } = await request.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: '请输入当前密码和新密码' }, { status: 400 })
}
if (newPassword.length < 8) {
return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 })
}
// 密码复杂度:大写/小写/数字/特殊字符 4选3
const hasUpper = /[A-Z]/.test(newPassword)
const hasLower = /[a-z]/.test(newPassword)
const hasDigit = /[0-9]/.test(newPassword)
const hasSpecial = /[^A-Za-z0-9]/.test(newPassword)
const complexityScore = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
if (complexityScore < 3) {
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
}
// 从 LLDAP 容器动态获取 admin 密码不硬编码admin 改密码后无需改 OA 配置)
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
const safeUser = session.username.replace(/'/g, "'\\''")
const safePass = newPassword.replace(/'/g, "'\\''")
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 })
if (stderr && !stderr.includes('Successfully')) {
return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err) {
const msg = err instanceof Error ? err.message : '修改失败'
if (msg.includes('command not found') || msg.includes('No such container')) {
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
}
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,13 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
}

View File

@ -1,9 +1,7 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
import { ldapAuth, isLldapAdmin } from '@/lib/ldap'
import { writeAuditLog } from '@/lib/audit'
import { syncUserToAllSites } from '@/lib/sync-user'
import { ldapAuth } from '@/lib/ldap'
export async function POST(request: Request) {
try {
@ -25,20 +23,6 @@ export async function POST(request: Request) {
const cookieStore = await cookies()
cookieStore.set(cfg.name, token, cfg)
// 审计日志
try {
writeAuditLog({
username: result.username!,
action: 'login',
details: { method: 'ldap', displayName: result.displayName },
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown',
})
} catch { /* 审计日志失败不影响登录 */ }
// 跨站点角色同步(不阻塞响应)
const role = (await isLldapAdmin(result.username!)) ? 'admin' : 'viewer'
syncUserToAllSites(result.username!, result.displayName!, role).catch(() => {})
return NextResponse.json({
user: { username: result.username, displayName: result.displayName },
})

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
import { ldapAuth } from '@/lib/ldap'
export async function POST(request: Request) {
try {
const { username, password } = await request.json()
if (!username || !password) {
return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 })
}
const result = await ldapAuth(username, password)
if (!result.success) {
if (result.unreachable) {
return NextResponse.json({ error: '认证服务暂时不可用,请稍后再试' }, { status: 503 })
}
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
const token = signSharedJwt({ username: result.username!, displayName: result.displayName! })
const cfg = sharedCookieConfig()
const cookieStore = await cookies()
cookieStore.set(cfg.name, token, cfg)
return NextResponse.json({
user: { username: result.username, displayName: result.displayName },
})
} catch {
return NextResponse.json({ error: '登录失败' }, { status: 500 })
}
}

View File

@ -7,7 +7,7 @@ const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
function getSiteUrl(): string {
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
return process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'
return process.env.NEXT_PUBLIC_SITE_URL || 'https://oa.tlyq.ai'
}
/** 清除 tlyq_session + session cookie → 302 跳转 /login */

View File

@ -0,0 +1,30 @@
// POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { NextResponse } from 'next/server'
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
// GET: 浏览器导航 → 302 到 Authelia end_session带 rd 回跳 /login
export async function GET() {
const returnUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'}/login`
const response = NextResponse.redirect(
`${autheliaUrl}/api/oidc/end_session?rd=${encodeURIComponent(returnUrl)}`
)
response.cookies.set('tlyq_session', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session', '', { path: '/', maxAge: 0 })
return response
}
// POST: fetch 调用 → 清除 cookie + 返回 200 JSON
export async function POST() {
const response = NextResponse.json({ success: true })
response.cookies.set('tlyq_session', '', {
httpOnly: true, secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
})
response.cookies.set('session', '', { path: '/', maxAge: 0 })
return response
}

View File

@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
const execAsync = promisify(exec)
async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> {
try {
const safeUser = username.replace(/'/g, "''")
const { stdout } = await execAsync(
`docker exec lldap /bin/sh -c "echo 'SELECT email, display_name FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`,
{ timeout: 3000 }
)
const parts = stdout.trim().split('|')
return { email: parts[0] || '', displayName: parts[1] || username }
} catch { return { email: '', displayName: username } }
}
export async function GET() {
try {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const payload = verifySharedJwt(token)
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const [admin, info] = await Promise.all([
isLldapAdmin(payload.username),
getLldapInfo(payload.username),
])
return NextResponse.json({
user: { username: payload.username, displayName: info.displayName, email: info.email, isAdmin: admin },
})
} catch {
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
}
}
export async function PUT(request: Request) {
try {
const cookieStore = await cookies()
const token = cookieStore.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
const payload = verifySharedJwt(token)
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
const { email } = await request.json()
if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
}
const safeUser = payload.username.replace(/'/g, "''")
const safeEmail = (email || '').replace(/'/g, "''")
const d = new Date()
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';`
await execAsync(
`docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`,
{ timeout: 5000 }
)
// 同步更新 assets / issue 本地用户表
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
for (const dbPath of [assetsDb, issueDb]) {
try {
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 })
} catch {}
}
return NextResponse.json({ success: true, email: email || '' })
} catch (e) {
const msg = e instanceof Error ? e.message : '修改失败'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySetupToken } from '@/lib/setup-token'
const execAsync = promisify(exec)
export async function POST(request: Request) {
try {
const { token, password } = await request.json()
if (!token || !password) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 })
}
const payload = verifySetupToken(token)
if (!payload) {
return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 })
}
if (password.length < 8) {
return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 })
}
const hasUpper = /[A-Z]/.test(password)
const hasLower = /[a-z]/.test(password)
const hasDigit = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
if (score < 3) {
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
}
const { stdout: adminPassOut } = await execAsync(
'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }
)
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
const safeUser = payload.username.replace(/'/g, "'\\''")
const safePass = password.replace(/'/g, "'\\''")
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
const { stderr } = await execAsync(cmd, { timeout: 10000 })
if (stderr && !stderr.includes('Successfully')) {
return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 })
}
return NextResponse.json({ success: true })
} catch (err) {
const msg = err instanceof Error ? err.message : '设置失败'
if (msg.includes('command not found') || msg.includes('No such container')) {
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
}
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'OK' })
}

View File

@ -1,17 +0,0 @@
// oa-ai/src/lib/audit.ts — 审计日志写入
import db from '@/lib/db'
export function writeAuditLog(params: {
username: string
action: string
details?: Record<string, unknown>
ipAddress: string
}) {
db.prepare(
`INSERT INTO audit_logs (username, action, details, ip_address)
VALUES (?, ?, ?, ?)`
).run(
params.username, params.action,
JSON.stringify(params.details || {}), params.ipAddress
)
}

View File

@ -1,23 +0,0 @@
// oa-ai/src/lib/db.ts — 仅为审计日志创建的 SQLite 数据库
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
const dbPath = process.env.DATABASE_PATH || './data/oa.db'
const dbDir = path.dirname(dbPath)
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true })
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
// 初始化 audit_logs 表
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
action TEXT,
details TEXT,
ip_address TEXT,
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
)`)
export default db

View File

@ -3,26 +3,17 @@ import { Client, InvalidCredentialsError } from 'ldapts'
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
// 从环境变量获取 LLDAP admin 密码(优先使用环境变量fallback 到 docker exec
// 从环境变量获取 LLDAP admin 密码(Docker 容器内无法执行 docker exec
function getLdapAdminPassword(): string {
if (process.env.LLDAP_ADMIN_PASSWORD) {
return process.env.LLDAP_ADMIN_PASSWORD
}
try {
const { execFileSync } = require('child_process') as typeof import('child_process')
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
{ timeout: 3000 }).toString().trim()
} catch {
throw new Error('无法获取 LLDAP admin 密码:请设置 LLDAP_ADMIN_PASSWORD 环境变量或确保 Docker socket 可用')
}
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
}
// 验证用户是否存在于 LLDAP 中(用于 OIDC callback 验证)
export async function ldapUserExists(username: string): Promise<boolean> {
const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}`
const adminPass = getLdapAdminPassword()
const client = new Client({ url: LDAP_URL, timeout: 5000 })
try {
const adminPass = getLdapAdminPassword()
await client.bind(adminDn, adminPass)
const { searchEntries } = await client.search(LDAP_BASE_DN, {
scope: 'sub', filter: `(uid=${username})`, timeLimit: 3,
@ -36,10 +27,10 @@ export async function ldapUserExists(username: string): Promise<boolean> {
export async function isLldapAdmin(username: string): Promise<boolean> {
if (username === 'admin') return true // 默认 admin 永远是管理员
const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}`
const adminPass = getLdapAdminPassword()
const client = new Client({ url: LDAP_URL, timeout: 5000 })
try {
const adminPass = getLdapAdminPassword()
await client.bind(adminDn, adminPass)
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
@ -51,7 +42,7 @@ export async function isLldapAdmin(username: string): Promise<boolean> {
} catch {
return false // LLDAP 不可达 → 保守拒绝,非 admin 不放行
} finally {
try { await client.unbind() } catch { /* */ }
await client.unbind()
}
}

View File

@ -1,34 +0,0 @@
// oa-ai/src/lib/sync-user.ts — 跨站点用户角色同步
const SYNC_TARGETS = [
{ key: 'MONITOR_INTERNAL_URL', name: 'monitor-ai' },
{ key: 'ASSETS_INTERNAL_URL', name: 'assets-ai' },
{ key: 'ISSUE_INTERNAL_URL', name: 'issue-ai' },
]
export async function syncUserToAllSites(
username: string,
displayName: string,
role: string
): Promise<void> {
const internalKey = process.env.INTERNAL_API_KEY
if (!internalKey) {
console.warn('同步跳过INTERNAL_API_KEY 未配置')
return
}
await Promise.allSettled(SYNC_TARGETS.map(({ key, name }) => {
const baseUrl = process.env[key]
if (!baseUrl) {
console.warn(`同步跳过 ${name}${key} 未配置`)
return Promise.resolve()
}
return fetch(`${baseUrl}/api/internal/users`, {
method: 'POST',
headers: {
'x-internal-key': internalKey || '',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, displayName, role }),
}).catch(e => console.error(`同步失败 ${name}:`, e))
}))
}