Compare commits

..

15 Commits

Author SHA1 Message Date
aiyimickey e639425885 fix: #26 跨站点 JWT role 修复 — logout 统一 + 审计日志 + 用户同步 2026-07-15 16:00:23 +08:00
aiyimickey e1fd098a97 feat: monitor permissions sync + universal docker exec for role writes + build freshness check
- role-manager.tsx: 添加同步到所有站点按钮 + 单用户单站点同步 + 同步结果显示详情
- roles/route.ts: 返回 display_name 修复下拉框显示 ()
- user-roles/route.ts: PUT 统一用 docker exec -i 写所有站点(绕过 SQLite 锁)
- sync-users/route.ts: 新增同步 API,统一 docker exec 写所有站点
- deploy-ai.sh: 构建新鲜度自动检查
- deploy-monitor.sh: 同上
2026-07-08 10:35:59 +08:00
aiyimickey 7365c4e3b8 fix: middleware publicPaths + /api/internal/users for OA monitor integration 2026-07-08 09:13:51 +08:00
aiyimickey 727269c877 feat: add monitor-ai permissions to user management page 2026-07-07 18:38:58 +08:00
aiyimickey b851fbbda8 feat: add /api/internal/roles endpoint for OA integration + middleware publicPaths 2026-07-07 18:33:35 +08:00
aiyimickey 8886e12b15 fix: command injection in lldap-db.ts — use execFileSync stdin instead of sh -c 2026-07-07 18:10:17 +08:00
aiyimickey ed43d7b8f7 fix: replace docker exec lldap with direct SQLite reads + fix docker.sock group access
- lldap-db.ts: 读直连 SQLite / 写走 docker exec(LLDAP DELETE 模式不可并发写)
- Dockerfile: Alpine addgroup 语法修复,nextjs 加入 docker 组
- docker-compose.yml: 挂载 LLDAP 数据目录,添加 docker.sock
- next.config.ts: outputFileTracingIncludes bcryptjs
- 所有 API 路由:替换 docker exec lldap 为 lldap-db 工具函数
- 安装 bcryptjs + sqlite3 依赖
2026-07-07 18:05:13 +08:00
aiyimickey 068c996e68 fix: logout redirect uses OIDC_REDIRECT_URI instead of request headers (open redirect fix) 2026-07-07 17:24:49 +08:00
aiyimickey 2e1fa92ca3 fix: OIDC callback V2 + logout 302 redirect with x-forwarded-host 2026-07-07 17:19:34 +08:00
aiyimickey 14c75a8942 chore: .env.example 统一为本地开发配置(JWT_SECRET/AUTHELIA_URL/COOKIE_DOMAIN) 2026-07-03 14:01:38 +08:00
aiyimickey aa44104095 fix: OA退出登录重定向修复 + SSO切换账号按钮 + 生产配置模板
- logout route 使用 request.url.origin 动态获取 base URL
- 登录页添加「使用其他账号登录」按钮
- 新增 config/.env.prod 生产环境配置模板
2026-07-03 12:12:06 +08:00
aiyimickey 34e685843e feat: OA 门户首页新增告警监控卡片 (monitor.tlyq.ai) 2026-07-02 11:25:20 +08:00
aiyimickey 69e50905d8 refactor: P1 共享库迁移
- jwt.ts: 引用 shared/lib/auth/jwt(包装器保持 signSharedJwt/verifySharedJwt)
- middleware.ts: 使用 shared/lib/auth/middleware 工厂
- tsconfig: 添加 @shared/* 路径
- 添加 shared symlink

独立审查通过
2026-07-01 18:43:01 +08:00
aiyimickey 511e1ec9dd feat: SSO 统一认证登录
- 集成 Authelia OIDC,支持统一认证登录
- 添加 OIDC 登录页面和回调处理
- 更新 docker-compose.yml 统一环境变量管理
- 更新 CHANGELOG.md 和 CLAUDE.md
2026-06-30 17:09:23 +08:00
aiyimickey dbde9a6058 fix: logout 端点添加 domain 参数清除跨域 cookie
- logout 端点清除 cookie 时添加 domain 参数
- 修复 .tlyq.ai 域的 tlyq_session cookie 未正确清除的问题
- cookie 设置时 domain=.tlyq.ai,清除时也必须指定相同 domain
2026-06-29 20:05:19 +08:00
39 changed files with 1152 additions and 541 deletions

View File

@ -1,11 +1,14 @@
# OA 门户环境变量 # OA 门户环境变量(本地开发)
LDAP_URL=ldap://localhost:3890 LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai LDAP_BASE_DN=dc=tlyq,dc=ai
JWT_SECRET=change-me-same-across-all-sites LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
JWT_SECRET=dev-jwt-secret-local
COOKIE_DOMAIN= COOKIE_DOMAIN=
NODE_ENV=development NODE_ENV=development
SMTP_HOST=smtphz.qiye.163.com NODE_TLS_REJECT_UNAUTHORIZED=0
SMTP_PORT=465
SMTP_USER=gxp@qx002575.com # OIDC 配置
SMTP_PASS= AUTHELIA_URL=http://127.0.0.1:6180
SMTP_FROM=gxp@qx002575.com OIDC_CLIENT_ID=oa-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6179/api/auth/callback

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ node_modules/
.env .env
.DS_Store .DS_Store
.env.local .env.local
.DS_Store
tsconfig.tsbuildinfo

View File

@ -1,5 +1,19 @@
# 变更日志 # 变更日志
## 2026-06-30
- [新增] SSO 统一认证:集成 Authelia OIDC支持统一认证登录
- [新增] OIDC 登录页面:添加「统一认证登录」按钮,支持 LDAP 回退
- [新增] `src/lib/oidc.ts`OIDC 客户端配置PKCE + state + nonce
- [新增] `src/app/api/auth/login/oidc/route.ts`OIDC 登录端点
- [新增] `src/app/api/auth/callback/route.ts`OIDC 回调处理
- [新增] `src/app/api/auth/logout/route.ts`:跨域登出(支持 domain 参数)
- [修复] NODE_TLS_REJECT_UNAUTHORIZED=0Authelia 使用自签名证书
- [修复] OIDC redirect_uri 回调重定向到 localhost添加 getBaseUrl() 函数
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
- [优化] docker-compose.yml统一环境变量管理移除 env_file
- [优化] .env.example添加 OIDC 配置模板
## 2026-05-18 ## 2026-05-18
- [安全] 邮件发送从 163 企业邮箱 SMTP → Resend APISending Access 权限),凭证从邮箱完整密码降级为仅可发信的 API Key - [安全] 邮件发送从 163 企业邮箱 SMTP → Resend APISending Access 权限),凭证从邮箱完整密码降级为仅可发信的 API Key

View File

@ -41,7 +41,11 @@ npm run build # 生产构建
| `src/app/login/page.tsx` | 登录页LLDAP 认证) | | `src/app/login/page.tsx` | 登录页LLDAP 认证) |
| `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) | | `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) |
| `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) | | `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) |
| `src/app/api/auth/login/route.ts` | 登录 APIOA 仅 LLDAP 认证,无本地 DB | | `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/logout/route.ts` | 退出 API清除 tlyq_session | | `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/auth/change-password/route.ts` | 修改密码docker exec 调 lldap_set_password |
| `src/app/api/admin/create-user/route.ts` | 创建用户SQLite 写 LLDAP + 自动同步站点 + 角色设置) | | `src/app/api/admin/create-user/route.ts` | 创建用户SQLite 写 LLDAP + 自动同步站点 + 角色设置) |
@ -78,6 +82,16 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
| `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) | | `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) |
| `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` | | `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` |
| `RESEND_API_KEY` | `re_xxxxxxxxxxxx` | Resend API KeySending Access 权限) | | `RESEND_API_KEY` | `re_xxxxxxxxxxxx` | Resend API KeySending Access 权限) |
| `AUTHELIA_URL` | `https://sso.tlyq.ai` | 同 |
| `OIDC_CLIENT_ID` | `oa-oidc` | 同 |
| `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` 示例 ### `.env` 示例
@ -89,6 +103,15 @@ JWT_SECRET=dev-secret-key-local
COOKIE_DOMAIN= COOKIE_DOMAIN=
NODE_ENV=development NODE_ENV=development
RESEND_API_KEY=re_xxxxxxxxxxxx RESEND_API_KEY=re_xxxxxxxxxxxx
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
``` ```
--- ---

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 将 nextjs 加入 docker 组Alpine: -g 而非 --gid允许访问 docker.sock
RUN addgroup -g 988 docker 2>/dev/null; addgroup nextjs docker
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# docker-cli: 密码修改等写操作仍需 docker exec lldapLLDAP 非 WAL 模式,不可并发写)
# sqlite: 直连只读查询 LLDAP/asstes/issue 数据库
# bcryptjs: OIDC callback 中 JWT 签发需要
RUN apk add --no-cache docker-cli sqlite && npm install bcryptjs
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

13
config/.env.prod Normal file
View File

@ -0,0 +1,13 @@
# OA 生产环境配置(模板)
# 真实密钥由首次部署时生成,后续部署不覆盖
LDAP_URL=ldap://lldap:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
JWT_SECRET=__JWT_SECRET__
COOKIE_DOMAIN=.tlyq.ai
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=oa-oidc
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
OIDC_REDIRECT_URI=https://oa.tlyq.ai/api/auth/callback

View File

@ -7,21 +7,40 @@ services:
environment: environment:
- LDAP_URL=ldap://lldap:3890 - LDAP_URL=ldap://lldap:3890
- LDAP_BASE_DN=dc=tlyq,dc=ai - LDAP_BASE_DN=dc=tlyq,dc=ai
- JWT_SECRET=oa-shared-jwt-secret-tlyq-2026 - LLDAP_ADMIN_PASSWORD=${LLDAP_ADMIN_PASSWORD}
- JWT_SECRET=${JWT_SECRET:-oa-shared-jwt-secret-tlyq-2026}
- COOKIE_DOMAIN=.tlyq.ai - COOKIE_DOMAIN=.tlyq.ai
- NODE_ENV=production - NODE_ENV=production
- NODE_TLS_REJECT_UNAUTHORIZED=0
- HOSTNAME=0.0.0.0
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db - ASSETS_DB_PATH=/data/other-sites/assets/assets.db
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db - ISSUE_DB_PATH=/data/other-sites/issue/issue.db
- RESEND_API_KEY=${RESEND_API_KEY} - RESEND_API_KEY=${RESEND_API_KEY}
- AUTHELIA_URL=${AUTHELIA_URL:-https://sso.tlyq.ai}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-oa-oidc}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-https://oa.tlyq.ai/api/auth/callback}
- ASSETS_INTERNAL_URL=http://assets-ai:3000
- ISSUE_INTERNAL_URL=http://issue-ai:3000
- MONITOR_INTERNAL_URL=http://monitor-ai:3000
- MONITOR_DB_PATH=/data/other-sites/monitor/monitor.db
volumes: volumes:
- ./.next:/app/.next - ./.next:/app/.next
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# 挂载整个数据目录(非单个文件),确保 SQLite WAL 文件共享 # 挂载外部数据目录(非单个文件),确保 SQLite WAL 文件共享
- /root/docker/ldap-ai/data/lldap:/data/other-sites/lldap
- /var/lib/docker/volumes/assets-ai_assets-data/_data:/data/other-sites/assets - /var/lib/docker/volumes/assets-ai_assets-data/_data:/data/other-sites/assets
- /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue - /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue
- /root/docker/monitor-ai/data:/data/other-sites/monitor
networks: networks:
- webnet - webnet
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
restart: unless-stopped restart: unless-stopped
networks: networks:

View File

@ -2,6 +2,9 @@ import type { NextConfig } from 'next'
const config: NextConfig = { const config: NextConfig = {
output: 'standalone', output: 'standalone',
outputFileTracingIncludes: {
'/api/**': ['./node_modules/bcryptjs/**/*'],
},
} }
export default config export default config

463
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "oa-ai", "name": "oa-ai",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"ldapts": "^6.0.0", "ldapts": "^6.0.0",
"next": "^15.0.0", "next": "^15.0.0",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
@ -17,6 +19,8 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.1",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
@ -1065,6 +1069,23 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"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": { "node_modules/@types/node": {
"version": "22.19.18", "version": "22.19.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz",
@ -1136,6 +1157,26 @@
"postcss": "^8.1.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@ -1149,6 +1190,49 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"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": { "node_modules/browserslist": {
"version": "4.28.4", "version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@ -1183,6 +1267,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001799", "version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@ -1203,6 +1311,12 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -1233,11 +1347,34 @@
} }
} }
}, },
"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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -1250,6 +1387,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/enhanced-resolve": {
"version": "5.21.6", "version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
@ -1274,12 +1420,27 @@
"node": ">=6" "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": { "node_modules/fast-sha256": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense" "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": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -1294,6 +1455,18 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1301,6 +1474,38 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@ -1633,6 +1838,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1657,6 +1889,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/next": {
"version": "15.5.18", "version": "15.5.18",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz",
@ -1737,6 +1975,18 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/node-releases": {
"version": "2.0.50", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@ -1765,6 +2015,15 @@
"node": "^10.13.0 || >=12.0.0" "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": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@ -1828,6 +2087,58 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@ -1849,6 +2160,20 @@
"react": "^19.2.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": { "node_modules/resend": {
"version": "6.12.3", "version": "6.12.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz", "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
@ -1870,6 +2195,26 @@
} }
} }
}, },
"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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -1887,7 +2232,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@ -1940,6 +2284,51 @@
"@img/sharp-win32-x64": "0.34.5" "@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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1965,6 +2354,24 @@
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
"license": "ISC" "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": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@ -2018,12 +2425,52 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2075,6 +2522,12 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@ -2089,6 +2542,12 @@
"uuid": "dist/bin/uuid" "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": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

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

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -33,8 +33,10 @@ export default function AdminUsersPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [assetsRole, setAssetsRole] = useState('viewer') const [assetsRole, setAssetsRole] = useState('viewer')
const [issueRole, setIssueRole] = useState('viewer') const [issueRole, setIssueRole] = useState('viewer')
const [monitorRole, setMonitorRole] = useState('viewer')
const [assetsRoles, setAssetsRoles] = useState<Role[]>([]) const [assetsRoles, setAssetsRoles] = useState<Role[]>([])
const [issueRoles, setIssueRoles] = useState<Role[]>([]) const [issueRoles, setIssueRoles] = useState<Role[]>([])
const [monitorRoles, setMonitorRoles] = useState<Role[]>([])
const [users, setUsers] = useState<LdapUser[]>([]) const [users, setUsers] = useState<LdapUser[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null) const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
@ -57,6 +59,7 @@ export default function AdminUsersPage() {
]) ])
if (rolesR.assets?.length) setAssetsRoles(rolesR.assets) if (rolesR.assets?.length) setAssetsRoles(rolesR.assets)
if (rolesR.issue?.length) setIssueRoles(rolesR.issue) if (rolesR.issue?.length) setIssueRoles(rolesR.issue)
if (rolesR.monitor?.length) setMonitorRoles(rolesR.monitor)
if (usersR.users?.length) setUsers(usersR.users) if (usersR.users?.length) setUsers(usersR.users)
} catch {} } catch {}
}, []) }, [])
@ -81,7 +84,7 @@ export default function AdminUsersPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
try { try {
const res = await fetch('/api/admin/create-user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, assetsRole, issueRole, email: email || undefined }) }) const res = await fetch('/api/admin/create-user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, assetsRole, issueRole, monitorRole, email: email || undefined }) })
const d = await res.json() const d = await res.json()
if (res.ok) { if (res.ok) {
if (d.password) { setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false) } if (d.password) { setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false) }
@ -206,6 +209,12 @@ export default function AdminUsersPage() {
{issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)} {issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)}
</select> </select>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={s.label}></label>
<select value={monitorRole} onChange={e => setMonitorRole(e.target.value)} style={s.select}>
{monitorRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)}
</select>
</div>
</div> </div>
</div> </div>
<button type="submit" disabled={loading} style={{ ...s.btn, background: loading ? '#93c5fd' : '#2563eb', cursor: loading ? 'not-allowed' : 'pointer' }}> <button type="submit" disabled={loading} style={{ ...s.btn, background: loading ? '#93c5fd' : '#2563eb', cursor: loading ? 'not-allowed' : 'pointer' }}>

View File

@ -4,8 +4,8 @@ import { useState, useEffect, useCallback } from 'react'
interface SiteUser { username: string; display_name: string; role: string } interface SiteUser { username: string; display_name: string; role: string }
interface RoleData { interface RoleData {
assetsUsers: SiteUser[]; issueUsers: SiteUser[] assetsUsers: SiteUser[]; issueUsers: SiteUser[]; monitorUsers: SiteUser[]
assetsRoles: string[]; issueRoles: string[] assetsRoles: string[]; issueRoles: string[]; monitorRoles: string[]
emails: Record<string, string> emails: Record<string, string>
} }
@ -33,11 +33,12 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
const [editingDisplayName, setEditingDisplayName] = useState<string | null>(null) const [editingDisplayName, setEditingDisplayName] = useState<string | null>(null)
const [editDisplayNameValue, setEditDisplayNameValue] = useState('') const [editDisplayNameValue, setEditDisplayNameValue] = useState('')
const [savingDisplayName, setSavingDisplayName] = useState(false) const [savingDisplayName, setSavingDisplayName] = useState(false)
const [syncing, setSyncing] = useState<string | null>(null) // username being synced, or 'all'
const fetchRoleData = useCallback(async () => { const fetchRoleData = useCallback(async () => {
try { try {
const res = await fetch('/api/admin/user-roles'); const d = await res.json() const res = await fetch('/api/admin/user-roles'); const d = await res.json()
if (d.users) setRoleData({ assetsUsers: d.users.assets || [], issueUsers: d.users.issue || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], emails: d.emails || {} }) if (d.users) setRoleData({ assetsUsers: d.users.assets || [], issueUsers: d.users.issue || [], monitorUsers: d.users.monitor || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], monitorRoles: d.monitorRoles || [], emails: d.emails || {} })
} catch {} } catch {}
}, []) }, [])
@ -99,11 +100,29 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
setPending({}); if (fail === 0) fetchRoleData(); setSaving(false) setPending({}); if (fail === 0) fetchRoleData(); setSaving(false)
} }
async function syncUsers(usernames: string[], targetSite?: string) {
setSyncing(usernames.length === 1 ? usernames[0] : 'all')
try {
const res = await fetch('/api/admin/sync-users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ usernames, targetSite }) })
const d = await res.json()
if (res.ok) {
fetchRoleData()
const failed = Object.entries(d.results || {}).flatMap(([site, users]: [string, any]) =>
Object.entries(users).filter(([, ok]) => !ok).map(([u]) => `${u}${site}`)
)
setResult(failed.length === 0, failed.length > 0 ? `${failed.join(', ')} 同步失败` : '同步完成')
}
else setResult(false, d.error || '同步失败')
} catch { setResult(false, '网络错误') }
finally { setSyncing(null) }
}
if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p> if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p>
const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string }>() const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string; monitorRole: string }>()
roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: u.role, issueRole: '—' })) roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: u.role, issueRole: '—', monitorRole: '—' }))
roleData.issueUsers.forEach(u => { const e = userMap.get(u.username); if (e) e.issueRole = u.role; else userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: u.role }) }) roleData.issueUsers.forEach(u => { const e = userMap.get(u.username); if (e) e.issueRole = u.role; else userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: u.role, monitorRole: '—' }) })
roleData.monitorUsers.forEach(u => { const e = userMap.get(u.username); if (e) e.monitorRole = u.role; else userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: '—', monitorRole: u.role }) })
const users = Array.from(userMap.entries()) const users = Array.from(userMap.entries())
const changed = Object.keys(pending).length const changed = Object.keys(pending).length
@ -111,15 +130,20 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
<div> <div>
<div style={ss.bar}> <div style={ss.bar}>
<p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p> <p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => syncUsers(users.map(([u]) => u))} disabled={syncing !== null} style={ss.saveBtn(false)}>
{syncing === 'all' ? '同步中...' : '同步到所有站点'}
</button>
<button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 0)}> <button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 0)}>
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`} {saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
</button> </button>
</div> </div>
</div>
<div style={{ overflowX: 'auto' }}><table style={ss.table}> <div style={{ overflowX: 'auto' }}><table style={ss.table}>
<thead> <thead>
<tr> <tr>
<th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th> <th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th><th style={ss.th}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -160,8 +184,9 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
</span> </span>
)} )}
</td> </td>
<td style={ss.td}><RoleCell site="assets" username={uname} originalRole={info.assetsRole} roles={roleData.assetsRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td> <td style={ss.td}><RoleCell site="assets" username={uname} originalRole={info.assetsRole} roles={roleData.assetsRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
<td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td> <td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
<td style={ss.td}><RoleCell site="monitor" username={uname} originalRole={info.monitorRole} roles={roleData.monitorRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} syncing={syncing} onSync={syncUsers} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -170,17 +195,27 @@ export default function RoleManager({ setResult, onUserUpdated }: Props) {
) )
} }
function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole }: { function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole, syncing, onSync }: {
site: string; username: string; originalRole: string; roles: string[] site: string; username: string; originalRole: string; roles: string[]
pending: Record<string, { site: string; newRole: string }> pending: Record<string, { site: string; newRole: string }>
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
getCurrentRole: (site: string, username: string, originalRole: string) => string getCurrentRole: (site: string, username: string, originalRole: string) => string
syncing: string | null; onSync: (usernames: string[], site?: string) => void
}) { }) {
const isReserved = username === 'admin' || username === 'localadmin' const isReserved = username === 'admin' || username === 'localadmin'
const changed = !!pending[`${site}:${username}`] const changed = !!pending[`${site}:${username}`]
const currentRole = getCurrentRole(site, username, originalRole) const currentRole = getCurrentRole(site, username, originalRole)
if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span> if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span>
if (originalRole === '—' && !changed) return <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span> if (originalRole === '—' && !changed) return (
<span style={{ fontSize: 11 }}>
<span style={{ color: 'var(--text-muted)' }}></span>
{' '}
<button onClick={() => onSync([username], site)} disabled={syncing === username}
style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: syncing === username ? 'not-allowed' : 'pointer', padding: 0, textDecoration: 'underline', opacity: syncing === username ? 0.5 : 1 }}>
{syncing === username ? '同步中...' : '同步'}
</button>
</span>
)
return ( return (
<select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}> <select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}>
{roles.map(r => <option key={r} value={r}>{r}</option>)} {roles.map(r => <option key={r} value={r}>{r}</option>)}

View File

@ -1,13 +1,12 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process' import { execFileSync } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { sendSetupLinkEmail } from '@/lib/email' import { sendSetupLinkEmail } from '@/lib/email'
import { signSetupToken } from '@/lib/setup-token' import { signSetupToken } from '@/lib/setup-token'
import { execLldap, lldapChangePassword, esc, getAdminPassword } from '@/lib/lldap-db'
const execAsync = promisify(exec) import bcrypt from 'bcryptjs'
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
@ -19,10 +18,8 @@ function generatePassword(): string {
const all = upper + lower + digits + special const all = upper + lower + digits + special
const crypto = globalThis.crypto const crypto = globalThis.crypto
const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length] const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length]
// 确保每种类型至少一个,其余随机填充到 12 位
let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special) let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special)
for (let i = 4; i < 12; i++) pwd += pick(all) for (let i = 4; i < 12; i++) pwd += pick(all)
// 打乱顺序
return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('') return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('')
} }
@ -34,28 +31,18 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
}) })
const data = await res.json() const data = await res.json()
return (data.roles || []).map((r: { name: string }) => r.name) return (data.roles || []).map((r: { name: string }) => r.name)
} catch { } catch { return [] }
return []
}
} }
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> { async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
try { try {
const res = await fetch(`${siteUrl}/api/auth/login`, { const res = await fetch(`${siteUrl}/api/auth/login`, {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}) })
return res.ok return res.ok
} catch { } catch { return false }
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) { export async function POST(request: Request) {
@ -68,81 +55,68 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 }) return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 })
} }
const { username, displayName, assetsRole, issueRole, email } = await request.json() const { username, displayName, assetsRole, issueRole, monitorRole, email } = await request.json()
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
if (!/^[a-z][a-z0-9_.@-]*$/i.test(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 password = generatePassword()
const ASSETS_URL = process.env.ASSETS_INTERNAL_URL || 'http://localhost:6177'
// 从各站点实时获取可用角色列表 const ISSUE_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176'
const [assetsRoles, issueRoles] = await Promise.all([ const MONITOR_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181'
fetchRoles('http://localhost:6177'), const [assetsRoles, issueRoles, monitorRoles] = await Promise.all([
fetchRoles('http://localhost:6176'), fetchRoles(ASSETS_URL), fetchRoles(ISSUE_URL), fetchRoles(MONITOR_URL),
]) ])
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer' const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer' const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer'
const mr = (monitorRole && monitorRoles.includes(monitorRole)) ? monitorRole : 'viewer'
const safeName = (displayName || username).replace(/'/g, "'\\''") const safeName = esc(displayName || username)
const safeUser = username.replace(/'/g, "'\\''") const safeUser = esc(username)
const lldapEmail = email || '' const lldapEmail = email || ''
const d = new Date() 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 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() const userUuid = crypto.randomUUID()
// 1. LLDAP SQLite 插入用户 // 1. docker exec lldap 插入用户LLDAP DELETE 模式不可并发写)
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}');` execLldap(`INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${safeUser}', '${esc(lldapEmail)}', '${safeName}', '${now}', '${userUuid}', LOWER('${esc(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 配置) // 2. bcryptjs 直写 LLDAP 密码(替代 docker exec lldap_set_password
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) lldapChangePassword(username, password)
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. 自动登录各站点触发用户同步 // 3. 自动登录各站点触发用户同步
const [assetsOk, issueOk] = await Promise.all([ const [assetsOk, issueOk, monitorOk] = await Promise.all([
syncToSite('http://localhost:6177', username, password), syncToSite(ASSETS_URL, username, password),
syncToSite('http://localhost:6176', username, password), syncToSite(ISSUE_URL, username, password),
syncToSite(MONITOR_URL, username, password),
]) ])
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值) // 4. 直接更新各站点角色
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
const roleResults = { assets: false, issue: false } const monitorDb = process.env.MONITOR_DB_PATH || '/data/other-sites/monitor/monitor.db'
if (assetsOk) { const roleResults = { assets: false, issue: false, monitor: false }
try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {} if (assetsOk) try { execFileSync('sqlite3', [assetsDb], { input: `UPDATE users SET role = '${ar}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.assets = true } catch {}
} if (issueOk) try { execFileSync('sqlite3', [issueDb], { input: `UPDATE users SET role = '${ir}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.issue = true } catch {}
if (issueOk) { if (monitorOk) try { execFileSync('sqlite3', [monitorDb], { input: `UPDATE users SET role = '${mr}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.monitor = true } catch {}
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
}
// 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) // 5. 如果提供了邮箱,发送密码设置链接
let emailSent = false let emailSent = false
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
try { try {
const setupToken = signSetupToken(username) const setupToken = signSetupToken(username)
const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}` await sendSetupLinkEmail(email, username, `https://oa.tlyq.ai/setup-password?token=${setupToken}`, displayName || username)
await sendSetupLinkEmail(email, username, setupUrl, displayName || username)
emailSent = true emailSent = true
} catch (e) { } catch (e) { console.error('发送邮件失败:', e) }
console.error('发送邮件失败:', e)
}
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
password: emailSent ? undefined : password, password: emailSent ? undefined : password,
synced: { assets: assetsOk, issue: issueOk }, synced: { assets: assetsOk, issue: issueOk, monitor: monitorOk },
roles: { assets: ar, issue: ir, applied: roleResults }, roles: { assets: ar, issue: ir, monitor: mr, applied: roleResults },
emailSent, emailSent,
message: emailSent message: emailSent ? `用户已创建,密码设置链接已发送至 ${email}` : '用户已创建并同步至所有站点',
? `用户已创建,密码设置链接已发送至 ${email}`
: '用户已创建并同步至所有站点',
}) })
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '创建失败' const msg = e instanceof Error ? e.message : '创建失败'

View File

@ -5,14 +5,24 @@ import { isLldapAdmin } from '@/lib/ldap'
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> { // 角色中文显示名映射
const ROLE_LABELS: Record<string, string> = {
admin: '管理员', editor: '编辑者', viewer: '观察者',
}
interface RoleInfo { name: string; display_name: string }
async function fetchRoles(siteUrl: string): Promise<RoleInfo[]> {
try { try {
const res = await fetch(`${url}/api/internal/roles`, { const res = await fetch(`${siteUrl}/api/internal/roles`, {
headers: { 'x-internal-key': INTERNAL_KEY }, headers: { 'x-internal-key': INTERNAL_KEY },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}) })
const data = await res.json() const data = await res.json()
return data.roles || [] return (data.roles || []).map((r: { name: string }) => ({
name: r.name,
display_name: ROLE_LABELS[r.name] || r.name,
}))
} catch { return [] } } catch { return [] }
} }
@ -23,10 +33,13 @@ export async function GET() {
const session = verifySharedJwt(token) const session = verifySharedJwt(token)
if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const [assetsRoles, issueRoles] = await Promise.all([ const A_URL = process.env.ASSETS_INTERNAL_URL || 'http://localhost:6177'
fetchRoles('http://localhost:6177'), const I_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176'
fetchRoles('http://localhost:6176'), const M_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181'
const [assets, issue, monitor] = await Promise.all([
fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL),
]) ])
return NextResponse.json({ assets: assetsRoles, issue: issueRoles }) return NextResponse.json({ assets, issue, monitor })
} }

View File

@ -1,13 +1,12 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process' import { execFileSync } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap, esc } from '@/lib/lldap-db'
const execAsync = promisify(exec) const ASSETS_DB = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
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 || '/data/other-sites/issue/issue.db'
const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
export async function POST() { export async function POST() {
try { try {
@ -19,23 +18,20 @@ export async function POST() {
return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 }) return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 })
} }
const { stdout } = await execAsync( const out = queryLldap(`SELECT user_id, email FROM users WHERE email != ''`)
`docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`, const lines = out.split('\n').filter(Boolean)
{ timeout: 5000 }
)
const lines = stdout.trim().split('\n').filter(Boolean)
let synced = 0 let synced = 0
for (const line of lines) { for (const line of lines) {
const [user, mail] = line.split('|') const [user, mail] = line.split('|')
const su = user.replace(/'/g, "''") const su = esc(user)
const sm = (mail || '').replace(/'/g, "''") const sm = esc(mail || '')
for (const db of [ASSETS_DB, ISSUE_DB]) { for (const db of [ASSETS_DB, ISSUE_DB]) {
try { try {
await execAsync( execFileSync('sqlite3', [db], {
`sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`, input: `UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';`,
{ timeout: 3000 } timeout: 3000,
) })
} catch {} } catch {}
} }
synced++ synced++

View File

@ -0,0 +1,67 @@
// POST /api/admin/sync-users — 将用户同步到指定站点docker exec 写 DB
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { execFileSync } from 'child_process'
import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap, esc } from '@/lib/lldap-db'
const SITES: Record<string, [string, string]> = {
assets: ['assets-ai', '/app/data/assets.db'],
issue: ['issue-ai', '/app/data/issue.db'],
monitor: ['monitor-ai', '/app/data/monitor.db'],
}
function syncToSite(container: string, dbPath: string, username: string, displayName: string, email: string): boolean {
try {
const su = esc(username); const sd = esc(displayName); const se = esc(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')}`
execFileSync('docker', ['exec', '-i', container, 'sqlite3', dbPath], {
input: `INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES ('${su}', '${sd}', '${se}', 'viewer', 1, '${now}', '${now}');`,
timeout: 5000,
})
return true
} catch { return false }
}
export async function POST(request: Request) {
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 { usernames, targetSite } = await request.json()
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
return NextResponse.json({ error: '请指定要同步的用户' }, { status: 400 })
}
// 从 LLDAP 获取用户信息
const userInfo: Record<string, { displayName: string; email: string }> = {}
try {
const safeNames = usernames.map(u => `'${esc(u)}'`).join(',')
const out = queryLldap(`SELECT user_id, display_name, email FROM users WHERE user_id IN (${safeNames})`)
out.split('\n').filter(Boolean).forEach(line => {
const [uid, dn, em] = line.split('|')
userInfo[uid] = { displayName: dn || uid, email: em || '' }
})
} catch {}
const sites = targetSite && SITES[targetSite] ? { [targetSite]: SITES[targetSite] }
: targetSite ? null : SITES
if (!sites) return NextResponse.json({ error: '无效的站点' }, { status: 400 })
const results: Record<string, Record<string, boolean>> = {}
for (const [site, [cName, cPath]] of Object.entries(sites)) {
results[site] = {}
for (const username of usernames) {
const info = userInfo[username] || { displayName: username, email: '' }
results[site][username] = syncToSite(cName, cPath, username, info.displayName, info.email)
}
}
return NextResponse.json({ success: true, results })
}

View File

@ -1,11 +1,10 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process' import { execFileSync } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap } from '@/lib/lldap-db'
const execAsync = promisify(exec)
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
async function fetchRoles(siteUrl: string): Promise<string[]> { async function fetchRoles(siteUrl: string): Promise<string[]> {
@ -19,13 +18,13 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
} catch { return [] } } catch { return [] }
} }
function queryDb(dbPath: string, sql: string): Promise<string> { function queryDb(dbPath: string, sql: string): string {
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '') try { return execFileSync('sqlite3', [dbPath, sql], { timeout: 3000, encoding: 'utf8' }).trim() } catch { return '' }
} }
async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> { 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;') const out = 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 => { return out.split('\n').filter(Boolean).map(line => {
const [username, display_name, role] = line.split('|') const [username, display_name, role] = line.split('|')
return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' } return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' }
}) })
@ -39,41 +38,60 @@ async function checkAdmin() {
return session ? isLldapAdmin(session.username) : false return session ? isLldapAdmin(session.username) : false
} }
const A_URL = process.env.ASSETS_INTERNAL_URL || 'http://localhost:6177'
const I_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176'
const M_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181'
const A_DB = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
const I_DB = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
const M_DB = process.env.MONITOR_DB_PATH || '/data/other-sites/monitor/monitor.db'
function siteDb(site: string): string {
if (site === 'assets') return A_DB
if (site === 'issue') return I_DB
return M_DB
}
function siteUrl(site: string): string {
if (site === 'assets') return A_URL
if (site === 'issue') return I_URL
return M_URL
}
// GET — 列出各站点用户及其角色 // GET — 列出各站点用户及其角色
export async function GET() { export async function GET() {
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try { try {
const [assetsRoles, issueRoles] = await Promise.all([ const [assetsRoles, issueRoles, monitorRoles] = await Promise.all([
fetchRoles('http://localhost:6177'), fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL),
fetchRoles('http://localhost:6176'),
]) ])
// monitor 走 APIDB 被 monitor 进程锁不可直连assets/issue 直连 SQLite
const [assetsUsers, issueUsers] = await Promise.all([ const [assetsUsers, issueUsers] = await Promise.all([
getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles), getSiteUsers(A_DB, assetsRoles),
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), getSiteUsers(I_DB, issueRoles),
]) ])
let monitorUsers: { username: string; display_name: string; role: string }[] = []
try {
const mRes = await fetch(`${M_URL}/api/internal/users`, { headers: { 'x-internal-key': INTERNAL_KEY }, signal: AbortSignal.timeout(5000) })
const mData = await mRes.json()
monitorUsers = (mData.users || []).map((u: any) => ({ username: u.username, display_name: u.display_name || u.username, role: monitorRoles.includes(u.role) ? u.role : 'viewer' }))
} catch { /* monitor 不可达时使用空列表 */ }
// 从 LLDAP 获取所有用户邮箱
let emails: Record<string, string> = {} let emails: Record<string, string> = {}
try { try {
const { stdout } = await execAsync( const out = queryLldap(`SELECT user_id, email FROM users`)
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`, out.split('\n').filter(Boolean).forEach(line => {
{ timeout: 3000 }
)
stdout.trim().split('\n').filter(Boolean).forEach(line => {
const [uid, e] = line.split('|') const [uid, e] = line.split('|')
emails[uid] = e || '' emails[uid] = e || ''
}) })
} catch {} } catch {}
return NextResponse.json({ return NextResponse.json({
assetsRoles, assetsRoles, issueRoles, monitorRoles,
issueRoles, users: { assets: assetsUsers, issue: issueUsers, monitor: monitorUsers },
users: { assets: assetsUsers, issue: issueUsers },
emails, emails,
}) })
} catch (e) { } catch {
return NextResponse.json({ error: '查询失败' }, { status: 500 }) return NextResponse.json({ error: '查询失败' }, { status: 500 })
} }
} }
@ -87,18 +105,22 @@ export async function PUT(request: Request) {
if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 }) if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 }) if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 })
const dbPath = site === 'assets' const roles = await fetchRoles(siteUrl(site))
? (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 }) 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 }) // 统一通过 docker exec -i 写各站点 DB直连 SQLite 在不同容器间始终 readonly
const containers: Record<string, [string, string]> = {
assets: ['assets-ai', '/app/data/assets.db'],
issue: ['issue-ai', '/app/data/issue.db'],
monitor: ['monitor-ai', '/app/data/monitor.db'],
}
const [cName, cPath] = containers[site] || [null, null]
if (!cName) return NextResponse.json({ error: '未知站点' }, { status: 400 })
const sql = `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`
execFileSync('docker', ['exec', '-i', cName, 'sqlite3', cPath], { input: sql, timeout: 5000 })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch {
return NextResponse.json({ error: '更新失败' }, { status: 500 }) return NextResponse.json({ error: '更新失败' }, { status: 500 })
} }
} }

View File

@ -1,11 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process' import { execFileSync } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap, execLldap, esc } from '@/lib/lldap-db'
const execAsync = promisify(exec)
function checkAdmin() { function checkAdmin() {
return async () => { return async () => {
@ -17,22 +15,28 @@ function checkAdmin() {
} }
} }
function siteSQL(dbPath: string, sql: string): void {
try { execFileSync('sqlite3', [dbPath], { input: sql, timeout: 3000 }) } catch {}
}
function nowStr(): string {
const d = new Date()
return `${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')}`
}
// GET — 列出 LLDAP 中所有用户 // GET — 列出 LLDAP 中所有用户
export async function GET() { export async function GET() {
const isAdmin = await checkAdmin()() const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try { try {
const { stdout } = await execAsync( const out = queryLldap(`SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC`)
`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"`, const users = out.split('\n').filter(Boolean).map(line => {
{ timeout: 5000 }
)
const users = stdout.trim().split('\n').filter(Boolean).map(line => {
const [user_id, email, display_name, creation_date] = line.split('|') const [user_id, email, display_name, creation_date] = line.split('|')
return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date } return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date }
}) })
return NextResponse.json({ users }) return NextResponse.json({ users })
} catch (e) { } catch {
return NextResponse.json({ error: '查询失败' }, { status: 500 }) return NextResponse.json({ error: '查询失败' }, { status: 500 })
} }
} }
@ -49,34 +53,24 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 }) return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 })
} }
const safeUser = username.replace(/'/g, "''") const safeUser = esc(username)
execLldap(`DELETE FROM users WHERE user_id='${safeUser}'`)
// 删除 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> = {} const results: Record<string, boolean> = {}
for (const [site, dbPath] of Object.entries({ for (const [site, dbPath] of Object.entries({
assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assets: process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db',
issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issue: process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db',
})) { })) {
try { try { siteSQL(dbPath, `DELETE FROM users WHERE username='${safeUser}'`); results[site] = true } catch { results[site] = false }
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 }) return NextResponse.json({ success: true, deleted: results })
} catch (e) { } catch {
return NextResponse.json({ error: '删除失败' }, { status: 500 }) return NextResponse.json({ error: '删除失败' }, { status: 500 })
} }
} }
// PATCH — 修改用户信息admin 权限) // PATCH — 修改用户信息
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
const isAdmin = await checkAdmin()() const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
@ -91,42 +85,30 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
} }
const safeUser = username.replace(/'/g, "''") const safeUser = esc(username)
const d = new Date() let lldapSets: string[] = [], siteSets: string[] = []
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) { if (email !== undefined) {
const safeEmail = (email || '').replace(/'/g, "''") const safeEmail = esc(email || '')
lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`) lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`)
siteSets.push(`email = '${safeEmail}'`) siteSets.push(`email = '${safeEmail}'`)
} }
if (displayName !== undefined) { if (displayName !== undefined) {
const safeName = displayName.replace(/'/g, "''") const safeName = esc(displayName)
lldapSets.push(`display_name = '${safeName}'`) lldapSets.push(`display_name = '${safeName}'`)
siteSets.push(`display_name = '${safeName}'`) siteSets.push(`display_name = '${safeName}'`)
} }
lldapSets.push(`modified_date = '${now}'`) lldapSets.push(`modified_date = '${nowStr()}'`)
siteSets.push(`updated_at = datetime('now', '+8 hours')`) siteSets.push(`updated_at = datetime('now', '+8 hours')`)
const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';` execLldap(`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 siteSql = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}'`
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' for (const dbPath of [process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db']) {
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' siteSQL(dbPath, siteSql)
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 }) return NextResponse.json({ success: true, username, email, displayName })
} catch (e) { } catch {
return NextResponse.json({ error: '修改失败' }, { status: 500 }) return NextResponse.json({ error: '修改失败' }, { status: 500 })
} }
} }

View File

@ -1,99 +1,35 @@
import { NextResponse } from 'next/server' // GET /api/auth/callback — OIDC callbackV2OA 签发 tlyq_session
import { cookies } from 'next/headers' import { NextRequest } from 'next/server'
import { getOidcClient } from '@/lib/oidc' import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' import { ldapUserExists } from '@/lib/ldap'
import { syncUserToAllSites } from '@/lib/sync-user'
// 从 OIDC_REDIRECT_URI 提取 base URL避免 request.url 使用 localhost const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
function getBaseUrl(): string { const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
const url = new URL(redirectUri) const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
return `${url.protocol}//${url.host}` 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, {
oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri },
jwtSecret,
cookieDomain,
// 强制验证 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
},
export async function GET(request: Request) { // OA 不通过 OIDC 创建/更新用户
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const baseUrl = getBaseUrl()
const cookieStore = await cookies()
// 1. 错误处理
if (error) {
return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl))
}
// 2. 验证 state
const savedState = cookieStore.get('oidc_state')?.value
if (!savedState || savedState !== state) {
return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl))
}
// 3. 取出 code_verifier
const codeVerifier = cookieStore.get('oidc_code_verifier')?.value
if (!codeVerifier) {
return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl))
}
// 4. 验证 nonce
const savedNonce = cookieStore.get('oidc_nonce')?.value
try {
// 5. 换取 token
const client = await getOidcClient()
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback'
const params = { code, state, iss: searchParams.get('iss') }
const checks = {
code_verifier: codeVerifier,
nonce: savedNonce,
state: savedState,
}
const tokenSet = await client.callback(redirectUri, params, checks)
// 6. 验证 nonce
if (savedNonce && tokenSet.claims) {
const claims = tokenSet.claims()
if (claims.nonce !== savedNonce) {
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl))
}
}
// 7. 获取 userinfo
const userinfo = await client.userinfo(tokenSet.access_token!)
// 8. 使用 preferred_username 作为用户名sub 可能是 UUID
const username = (userinfo as any).preferred_username || userinfo.sub!
const displayName = userinfo.name || username
// 9. 签发 tlyq_session cookie
const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string })
const cfg = sharedCookieConfig()
const response = NextResponse.redirect(new URL('/', baseUrl))
response.cookies.set(cfg.name, sharedToken, cfg)
// 10. 存储 id_token 用于登出
if (tokenSet.id_token) {
response.cookies.set('oidc_id_token', tokenSet.id_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 86400,
path: '/',
}) })
}
// 11. 清理 OIDC 临时 cookie
response.cookies.delete('oidc_state')
response.cookies.delete('oidc_nonce')
response.cookies.delete('oidc_code_verifier')
return response return response
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('OIDC callback error:', errorMsg)
return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl))
}
} }

View File

@ -1,10 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { lldapChangePassword, getAdminPassword } from '@/lib/lldap-db'
const execAsync = promisify(exec)
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -22,7 +19,6 @@ export async function POST(request: Request) {
if (newPassword.length < 8) { if (newPassword.length < 8) {
return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 }) return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 })
} }
// 密码复杂度:大写/小写/数字/特殊字符 4选3
const hasUpper = /[A-Z]/.test(newPassword) const hasUpper = /[A-Z]/.test(newPassword)
const hasLower = /[a-z]/.test(newPassword) const hasLower = /[a-z]/.test(newPassword)
const hasDigit = /[0-9]/.test(newPassword) const hasDigit = /[0-9]/.test(newPassword)
@ -32,25 +28,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
} }
// 从 LLDAP 容器动态获取 admin 密码不硬编码admin 改密码后无需改 OA 配置) // 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) lldapChangePassword(session.username, newPassword)
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 }) return NextResponse.json({ success: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : '修改失败' 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 }) return NextResponse.json({ error: msg }, { status: 500 })
} }
} }

View File

@ -1,81 +1,13 @@
import { NextResponse } from 'next/server' // GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { cookies } from 'next/headers' import { handleOidcLogin } from '@shared/lib/auth/handle-login'
import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc'
import { verifySharedJwt } from '@/lib/jwt' 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) { export async function GET(request: Request) {
const cookieStore = await cookies()
const existingSession = cookieStore.get('tlyq_session')?.value
const url = new URL(request.url) const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1' const switchUser = url.searchParams.get('switch') === '1'
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
// 检查是否已有登录用户
if (existingSession && !switchUser) {
const existing = verifySharedJwt(existingSession)
if (existing) {
return NextResponse.json({
conflict: true,
currentUser: existing.username,
displayName: existing.displayName,
})
}
}
// 预检 Authelia 健康状态
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
try {
const healthRes = await fetch(`${autheliaUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
})
if (!healthRes.ok) {
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 })
}
} catch {
return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 })
}
// 生成 PKCE 参数
const { codeVerifier, codeChallenge } = generatePKCE()
const state = generateState()
const nonce = generateNonce()
// 构建授权 URL
const client = await getOidcClient()
const authorizationUrl = client.authorizationUrl({
scope: 'openid profile email',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
...(switchUser && { prompt: 'login' }),
})
// 存储到 httpOnly cookie5 分钟过期)
const response = NextResponse.redirect(authorizationUrl)
response.cookies.set('oidc_code_verifier', codeVerifier, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
response.cookies.set('oidc_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
response.cookies.set('oidc_nonce', nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 300,
path: '/',
})
return response
} }

View File

@ -1,7 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt'
import { ldapAuth } from '@/lib/ldap' import { ldapAuth, isLldapAdmin } from '@/lib/ldap'
import { writeAuditLog } from '@/lib/audit'
import { syncUserToAllSites } from '@/lib/sync-user'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -23,6 +25,20 @@ export async function POST(request: Request) {
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set(cfg.name, token, cfg) 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({ return NextResponse.json({
user: { username: result.username, displayName: result.displayName }, user: { username: result.username, displayName: result.displayName },
}) })

View File

@ -1,15 +1,25 @@
import { NextResponse } from 'next/server' // GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
import { cookies } from 'next/headers' import { NextResponse, type NextRequest } from 'next/server'
export async function POST() { const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
const cookieStore = await cookies()
// 清除所有相关 cookie /** 从 OIDC_REDIRECT_URI 提取 site URL不可信请求头见 LESSONS-LEARNED #51 */
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' }) function getSiteUrl(): string {
cookieStore.set('session', '', { maxAge: 0, path: '/' }) const redirectUri = process.env.OIDC_REDIRECT_URI || ''
cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/' }) try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
return process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'
// Authelia 4.38 不支持 end_session_endpoint直接跳转登录页
// Authelia session 会在 cookie 过期后自动清除
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://127.0.0.1:6179'))
} }
/** 清除 tlyq_session + session cookie → 302 跳转 /login */
function logoutResponse(): NextResponse {
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
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
}
export async function GET() { return logoutResponse() }
export async function POST() { return logoutResponse() }

View File

@ -1,20 +1,15 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { queryLldap, execLldap, esc } from '@/lib/lldap-db'
const execAsync = promisify(exec) import { execFileSync } from 'child_process'
async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> { async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> {
try { try {
const safeUser = username.replace(/'/g, "''") const safe = esc(username)
const { stdout } = await execAsync( const out = queryLldap(`SELECT email, display_name FROM users WHERE user_id = '${safe}'`)
`docker exec lldap /bin/sh -c "echo 'SELECT email, display_name FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`, const parts = out.split('|')
{ timeout: 3000 }
)
const parts = stdout.trim().split('|')
return { email: parts[0] || '', displayName: parts[1] || username } return { email: parts[0] || '', displayName: parts[1] || username }
} catch { return { email: '', displayName: username } } } catch { return { email: '', displayName: username } }
} }
@ -55,23 +50,23 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
} }
const safeUser = payload.username.replace(/'/g, "''") const safeUser = esc(payload.username)
const safeEmail = (email || '').replace(/'/g, "''") const safeEmail = esc(email || '')
const d = new Date() 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 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}';` // docker exec lldap 更新邮箱LLDAP DELETE 模式不可并发写)
await execAsync( execLldap(`UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}'`)
`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 本地用户表 // 同步更新 assets / issue 本地用户表
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
for (const dbPath of [assetsDb, issueDb]) { for (const dbPath of [assetsDb, issueDb]) {
try { try {
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 }) execFileSync('sqlite3', [dbPath], {
input: `UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`,
timeout: 3000,
})
} catch {} } catch {}
} }

View File

@ -1,9 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { exec } from 'child_process'
import { promisify } from 'util'
import { verifySetupToken } from '@/lib/setup-token' import { verifySetupToken } from '@/lib/setup-token'
import { lldapChangePassword } from '@/lib/lldap-db'
const execAsync = promisify(exec)
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -29,26 +26,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
} }
const { stdout: adminPassOut } = await execAsync( // 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec
'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 } lldapChangePassword(payload.username, password)
)
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 }) return NextResponse.json({ success: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : '设置失败' 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 }) 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,9 +1,9 @@
'use client' 'use client'
import { useState } from 'react' import { useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
export default function LoginPage() { function LoginPageContent() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@ -52,6 +52,11 @@ export default function LoginPage() {
</button> </button>
<p className="text-center text-xs text-slate-400 mb-3"> SSO </p> <p className="text-center text-xs text-slate-400 mb-3"> SSO </p>
<p className="text-center mb-2">
<button onClick={() => { window.location.href = '/api/auth/login/oidc?switch=1' }} className="text-xs text-slate-400 hover:text-slate-600 underline">
使
</button>
</p>
<p className="text-center"> <p className="text-center">
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline"> <button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
使 LDAP 使 LDAP
@ -86,3 +91,11 @@ export default function LoginPage() {
</div> </div>
) )
} }
export default function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">...</div>}>
<LoginPageContent />
</Suspense>
)
}

View File

@ -13,6 +13,7 @@ function siteUrl(url: string, domain: string): string {
const CORE_SITES = [ const CORE_SITES = [
{ name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' }, { name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
{ name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' }, { name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' },
{ name: '告警监控', url: 'http://127.0.0.1:6181', desc: '统一告警监控中心,容器/端点健康检查,企业微信告警推送', tag: 'MONITOR', dot: '#4f46e5', domain: 'monitor.tlyq.ai' },
] ]
const OTHER_SITES = [ const OTHER_SITES = [
@ -25,6 +26,7 @@ const OTHER_SITES = [
const COLORS: Record<string, { light: string; tag: string }> = { const COLORS: Record<string, { light: string; tag: string }> = {
'#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' }, '#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' },
'#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' }, '#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' },
'#4f46e5': { light: 'rgba(79,70,229,0.08)', tag: '#4f46e5' },
'#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' }, '#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' },
'#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' }, '#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' },
'#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' }, '#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' },

17
src/lib/audit.ts Normal file
View File

@ -0,0 +1,17 @@
// 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
)
}

23
src/lib/db.ts Normal file
View File

@ -0,0 +1,23 @@
// 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

@ -1,6 +1,8 @@
import crypto from 'crypto' // oa-ai/src/lib/jwt.ts — V2使用 signJwtV2含 iss: 'oa.tlyq.ai'),旧函数保留兼容
import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites' const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-change-me'
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '' const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || ''
export interface SharedSession { export interface SharedSession {
@ -10,51 +12,34 @@ export interface SharedSession {
exp: number exp: number
} }
function base64url(str: string): string { // 保持原有签名signSharedJwt(payload, expiresIn) → 内部改用 signJwtV2
return Buffer.from(str).toString('base64url')
}
export function signSharedJwt( export function signSharedJwt(
payload: { username: string; displayName: string }, payload: { username: string; displayName: string },
expiresIn: number = 7 * 24 * 60 * 60 expiresIn: number = 7 * 24 * 60 * 60
): string { ): string {
const header = { alg: 'HS256', typ: 'JWT' } return signJwtV2({ secret: JWT_SECRET, payload, iss: 'oa.tlyq.ai', expiresInSeconds: expiresIn })
const now = Math.floor(Date.now() / 1000)
const body = { ...payload, iat: now, exp: now + expiresIn }
const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))]
const signingInput = segments.join('.')
segments.push(
crypto.createHmac('sha256', JWT_SECRET).update(signingInput).digest('base64url')
)
return segments.join('.')
} }
// 保持原有签名verifySharedJwt(token) — 兼容旧 token无 iss和新 token有 iss
export function verifySharedJwt(token: string): SharedSession | null { export function verifySharedJwt(token: string): SharedSession | null {
try { const payload = verifyJwt(token, JWT_SECRET)
const parts = token.split('.') if (!payload) return null
if (parts.length !== 3) return null
const signingInput = parts.slice(0, 2).join('.')
const expectedSig = crypto.createHmac('sha256', JWT_SECRET)
.update(signingInput).digest('base64url')
if (parts[2] !== expectedSig) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null
return { return {
username: payload.username, username: payload.username as string,
displayName: payload.displayName, displayName: (payload.displayName || payload.username) as string,
iat: payload.iat, iat: payload.iat as number,
exp: payload.exp, exp: payload.exp as number,
} }
} catch { return null }
} }
// 保持原有签名sharedCookieConfig(maxAge)
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) { export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
return { return {
name: 'tlyq_session', name: 'tlyq_session',
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const, sameSite: 'lax' as const,
domain: COOKIE_DOMAIN, domain: COOKIE_DOMAIN || undefined,
path: '/', path: '/',
maxAge, maxAge,
} }

View File

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

37
src/lib/lldap-db.ts Normal file
View File

@ -0,0 +1,37 @@
// lib/lldap-db.ts — LLDAP 操作(读直连 SQLite / 写走 docker exec stdin
// LLDAP 使用 DELETE journal mode不可并发写 → 写操作必须通过 docker exec 在 LLDAP 容器内执行
// SQL 通过 stdin 传入 sqlite3不经过 shell 解析 → 无命令注入风险
import { execFileSync } from 'child_process'
import bcrypt from 'bcryptjs'
const LLDAP_DB = process.env.LLDAP_DB_PATH || '/data/other-sites/lldap/users.db'
/** 直连 LLDAP SQLite 只读查询 */
export function queryLldap(sql: string): string {
return execFileSync('sqlite3', [LLDAP_DB, sql], { timeout: 5000, encoding: 'utf8' }).trim()
}
/** docker exec -i lldap sqlite3通过 stdin 传入 SQL无 shell 解析,无命令注入风险) */
export function execLldap(sql: string, timeout = 5000): string {
return execFileSync('docker', ['exec', '-i', 'lldap', 'sqlite3', '/data/users.db'], {
input: sql, timeout, encoding: 'utf8',
}).trim()
}
/** 安全的 SQL 字符串转义SQLite 标准:'' → 单引号) */
export function esc(val: string): string {
return val.replace(/'/g, "''")
}
/** 修改 LLDAP 用户密码 */
export function lldapChangePassword(username: string, newPassword: string): void {
const hash = bcrypt.hashSync(newPassword, 12)
const safeUser = esc(username)
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
execLldap(`UPDATE users SET password_hash = '${hash}', password_modified_date = '${now}', modified_date = '${now}' WHERE user_id = '${safeUser}';`)
}
/** 获取 admin 密码 */
export function getAdminPassword(): string {
return process.env.LLDAP_ADMIN_PASSWORD || 'admin123'
}

View File

@ -23,6 +23,7 @@ export async function getOidcClient() {
client_secret: OIDC_CLIENT_SECRET, client_secret: OIDC_CLIENT_SECRET,
redirect_uris: [OIDC_REDIRECT_URI], redirect_uris: [OIDC_REDIRECT_URI],
response_types: ['code'], response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
}) })
lastDiscovery = now lastDiscovery = now
return oidcClient return oidcClient

34
src/lib/sync-user.ts Normal file
View File

@ -0,0 +1,34 @@
// 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))
}))
}

View File

@ -1,72 +1,14 @@
import { NextResponse } from 'next/server' // src/middleware.ts — V2 单 cookie 模型Edge 验签 + iss 校验
import type { NextRequest } from 'next/server' import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
function decodeJwtPayload(token: string): Record<string, unknown> | null { const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
try { const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
const parts = token.split('.')
if (parts.length !== 3) return null
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
while (payload.length % 4) payload += '='
return JSON.parse(atob(payload))
} catch { return null }
}
function isValidPayload(payload: Record<string, unknown> | null): boolean { export const middleware = createMiddlewareV2({
if (!payload) return false jwtSecret,
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000)) cookieDomain,
} allowedIssuers: ['*'], // 迁移模式
publicPaths: ['/login', '/api/auth', '/api/health', '/api/admin', '/setup-password', '/_next', '/favicon.ico'],
function noCache(response: NextResponse) {
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
return response
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 登录页:已登录用户自动跳转首页
if (pathname === '/login') {
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
// 设置密码/API 路径放行API 路由自行验证)
if (pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
return NextResponse.next()
}
// /admin 管理页面需要认证
if (pathname.startsWith('/admin')) {
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (!isValidPayload(payload)) {
return NextResponse.redirect(new URL('/login', request.url))
}
return noCache(NextResponse.next())
}
// 静态资源放行(已有路径哈希,允许缓存)
if (pathname.startsWith('/_next/') || pathname === '/favicon.ico') {
return NextResponse.next()
}
const token = request.cookies.get('tlyq_session')?.value
const payload = token ? decodeJwtPayload(token) : null
if (isValidPayload(payload)) {
const response = NextResponse.next()
response.cookies.set('session', JSON.stringify({ username: payload!.username }), {
httpOnly: true,
sameSite: 'lax',
path: '/',
}) })
return noCache(response)
}
return noCache(NextResponse.redirect(new URL('/login', request.url)))
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }

View File

@ -22,9 +22,13 @@
"name": "next" "name": "next"
} }
], ],
"baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
],
"@shared/*": [
"./shared/*"
] ]
} }
}, },

File diff suppressed because one or more lines are too long