Compare commits
25 Commits
v2026.05.1
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
e639425885 | |
|
|
e1fd098a97 | |
|
|
7365c4e3b8 | |
|
|
727269c877 | |
|
|
b851fbbda8 | |
|
|
8886e12b15 | |
|
|
ed43d7b8f7 | |
|
|
068c996e68 | |
|
|
2e1fa92ca3 | |
|
|
14c75a8942 | |
|
|
aa44104095 | |
|
|
34e685843e | |
|
|
69e50905d8 | |
|
|
511e1ec9dd | |
|
|
dbde9a6058 | |
|
|
ec4bbcb71b | |
|
|
ae1e58a595 | |
|
|
868c79891f | |
|
|
8dbb841489 | |
|
|
025f56e163 | |
|
|
ce3a26ab4c | |
|
|
32793f7c72 | |
|
|
14abdff875 | |
|
|
ab25541200 | |
|
|
561ab6d177 |
17
.env.example
17
.env.example
|
|
@ -1,11 +1,14 @@
|
|||
# OA 门户环境变量
|
||||
# OA 门户环境变量(本地开发)
|
||||
LDAP_URL=ldap://localhost:3890
|
||||
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=
|
||||
NODE_ENV=development
|
||||
SMTP_HOST=smtphz.qiye.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=gxp@qx002575.com
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=gxp@qx002575.com
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# OIDC 配置
|
||||
AUTHELIA_URL=http://127.0.0.1:6180
|
||||
OIDC_CLIENT_ID=oa-oidc
|
||||
OIDC_CLIENT_SECRET=<见 Authelia 配置>
|
||||
OIDC_REDIRECT_URI=http://127.0.0.1:6179/api/auth/callback
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@ node_modules/
|
|||
.next/
|
||||
.env
|
||||
.DS_Store
|
||||
.env.local
|
||||
.DS_Store
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,5 +1,33 @@
|
|||
# 变更日志
|
||||
|
||||
## 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=0:Authelia 使用自签名证书
|
||||
- [修复] OIDC redirect_uri 回调重定向到 localhost:添加 getBaseUrl() 函数
|
||||
- [修复] token_endpoint_auth_method 配置缺失:添加 client_secret_basic
|
||||
- [优化] docker-compose.yml:统一环境变量管理,移除 env_file
|
||||
- [优化] .env.example:添加 OIDC 配置模板
|
||||
|
||||
## 2026-05-18
|
||||
|
||||
- [安全] 邮件发送从 163 企业邮箱 SMTP → Resend API(Sending Access 权限),凭证从邮箱完整密码降级为仅可发信的 API Key
|
||||
- [安全] 创建用户不再邮件发送明文密码,改为发送一次性密码设置链接(JWT token,24 小时有效),密码全程只有用户自己知道
|
||||
- [新增] `/setup-password` 密码设置页(公开,通过邮件链接访问)+ `/api/auth/setup-password` API
|
||||
- [新增] `src/lib/setup-token.ts` 一次性密码设置 token 签发/验证
|
||||
- [新增] 个人信息页 显示/修改邮箱(EmailEditor 客户端组件,inline 编辑)
|
||||
- [新增] 权限管理页 邮箱列 + admin 编辑任意用户邮箱
|
||||
- [修复] 创建用户时 LLDAP 存储真实邮箱(不再自动拼接 `{username}@tlyq.ai`)
|
||||
- [修复] 全站时区统一 UTC+8:修复 4 处 `toISOString()` + 1 处 `datetime('now')` 无偏移
|
||||
- [设计] 用户管理页全宽重设计:顶部 Tab 条 + 表格化列表 + toast 通知 + Header 宽度 1440px 统一
|
||||
- [调整] 用户列表创建时间精确到秒
|
||||
- [调整] 邮件模板移除 163 管理员联系方式
|
||||
|
||||
## 2026-05-14
|
||||
|
||||
- [新增] 管理员权限接入 LLDAP `lldap_admin` 组:`isLldapAdmin()` 通过 LDAP 查询组成员决定管理权限,不再硬编码 `username === 'admin'`;将用户加入该组即可获得 OA 管理权限
|
||||
|
|
|
|||
57
CLAUDE.md
57
CLAUDE.md
|
|
@ -33,12 +33,19 @@ npm run build # 生产构建
|
|||
| `src/middleware.ts` | 全局路由守卫:检查 `tlyq_session` cookie,未认证跳转 /login |
|
||||
| `src/lib/jwt.ts` | 共享 JWT 签发/验证(HS256,与 assets/issue 共用密钥和格式) |
|
||||
| `src/lib/ldap.ts` | LLDAP 认证(ldapAuth)+ 密码修改(ldapChangePassword)+ 用户存在性检查 |
|
||||
| `src/lib/email.ts` | 邮件发送(nodemailer,163 企业邮箱,创建用户时发送凭证) |
|
||||
| `src/lib/email.ts` | 邮件发送(Resend API,创建用户时发送密码设置链接,不含明文密码) |
|
||||
| `src/lib/setup-token.ts` | 一次性密码设置 token 签发/验证(JWT,24 小时有效) |
|
||||
| `src/app/setup-password/page.tsx` | 密码设置页(公开,通过邮件链接 token 访问) |
|
||||
| `src/app/api/auth/setup-password/route.ts` | 密码设置 API(验证 token + 调用 lldap_set_password) |
|
||||
| `src/app/page.tsx` | 门户首页:站点卡片导航(核心系统 + 其他站点) |
|
||||
| `src/app/login/page.tsx` | 登录页(LLDAP 认证) |
|
||||
| `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) |
|
||||
| `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) |
|
||||
| `src/app/api/auth/login/route.ts` | 登录 API(OA 仅 LLDAP 认证,无本地 DB) |
|
||||
| `src/app/api/auth/login/route.ts` | 登录 API(LDAP 认证 + 审计日志 + 跨站点角色同步) |
|
||||
| `src/app/api/auth/callback/route.ts` | OIDC callback(handleOidcCallback + 跨站点角色同步) |
|
||||
| `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/change-password/route.ts` | 修改密码(docker exec 调 lldap_set_password) |
|
||||
| `src/app/api/admin/create-user/route.ts` | 创建用户(SQLite 写 LLDAP + 自动同步站点 + 角色设置) |
|
||||
|
|
@ -71,14 +78,20 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
|
|||
| `LDAP_URL` | `ldap://localhost:3890` | `ldap://lldap:3890` |
|
||||
| `LDAP_BASE_DN` | `dc=tlyq,dc=ai` | 同 |
|
||||
| `LDAP_ADMIN_DN` | `uid=admin,ou=people,dc=tlyq,dc=ai` | 同 |
|
||||
| `LDAP_ADMIN_PASS` | `admin123` | LLDAP admin 密码 |
|
||||
| — | 运行时动态读取 | LLDAP admin 密码通过 `docker exec lldap printenv` 获取,不存本地 |
|
||||
| `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) |
|
||||
| `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` |
|
||||
| `SMTP_HOST` | `smtphz.qiye.163.com` | 163 企业邮箱 |
|
||||
| `SMTP_PORT` | `465` | SSL 端口 |
|
||||
| `SMTP_USER` | `gxp@qx002575.com` | 发件邮箱 |
|
||||
| `SMTP_PASS` | 见 .env | 邮箱密码 |
|
||||
| `SMTP_FROM` | `gxp@qx002575.com` | 发件人地址 |
|
||||
| `RESEND_API_KEY` | `re_xxxxxxxxxxxx` | Resend API Key(Sending 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` 示例
|
||||
|
||||
|
|
@ -86,15 +99,19 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
|
|||
LDAP_URL=ldap://localhost:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_DN=uid=admin,ou=people,dc=tlyq,dc=ai
|
||||
LDAP_ADMIN_PASS=admin123
|
||||
JWT_SECRET=dev-secret-key-local
|
||||
COOKIE_DOMAIN=
|
||||
NODE_ENV=development
|
||||
SMTP_HOST=smtphz.qiye.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=gxp@qx002575.com
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=gxp@qx002575.com
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -136,6 +153,18 @@ txjp 服务器
|
|||
|
||||
---
|
||||
|
||||
## Git Tag 规范
|
||||
|
||||
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.05.18`)。提交后打 tag 再推送:
|
||||
|
||||
```bash
|
||||
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
|
||||
```
|
||||
|
||||
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`。
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### OA 502 Bad Gateway
|
||||
|
|
|
|||
|
|
@ -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 lldap(LLDAP 非 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"]
|
||||
|
|
@ -46,5 +46,5 @@ npm run dev # http://localhost:6179
|
|||
|
||||
## 相关文档
|
||||
|
||||
- [OA 设计文档](../docs/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
|
||||
- [OA 设计文档](../docs/design/OA-DESIGN.md) — 完整架构、认证流程、迁移步骤
|
||||
- [OA UI 设计](../docs/OA-UI-DESIGN.md) — 页面设计、配色方案
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -7,25 +7,40 @@ services:
|
|||
environment:
|
||||
- LDAP_URL=ldap://lldap:3890
|
||||
- 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
|
||||
- NODE_ENV=production
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
- HOSTNAME=0.0.0.0
|
||||
- TZ=Asia/Shanghai
|
||||
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db
|
||||
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db
|
||||
- SMTP_HOST=smtphz.qiye.163.com
|
||||
- SMTP_PORT=465
|
||||
- SMTP_USER=gxp@qx002575.com
|
||||
- SMTP_PASS=qhQcTaR6rAzCnHQk
|
||||
- SMTP_FROM=gxp@qx002575.com
|
||||
- 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:
|
||||
- ./.next:/app/.next
|
||||
- /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/issue-ai_issue-data/_data:/data/other-sites/issue
|
||||
- /root/docker/monitor-ai/data:/data/other-sites/monitor
|
||||
networks:
|
||||
- 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
|
||||
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import type { NextConfig } from 'next'
|
|||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
outputFileTracingIncludes: {
|
||||
'/api/**': ['./node_modules/bcryptjs/**/*'],
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
|
@ -8,16 +8,24 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"ldapts": "^6.0.0",
|
||||
"next": "^15.0.0",
|
||||
"nodemailer": "^8.0.7",
|
||||
"openid-client": "^5.7.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"resend": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
const config = { plugins: { '@tailwindcss/postcss': {} } }
|
||||
export default config
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// 批量同步 LLDAP email → assets / issue 本地用户表
|
||||
// 每次 OA 部署后自动执行,确保新服务器历史数据也能填充
|
||||
const { exec } = require('child_process')
|
||||
const { promisify } = require('util')
|
||||
const e = promisify(exec)
|
||||
|
||||
const SITES = [
|
||||
{ name: 'assets', db: process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db' },
|
||||
{ name: 'issue', db: process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db' },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const r = await e(
|
||||
`docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`
|
||||
)
|
||||
const lines = r.stdout.trim().split('\n').filter(Boolean)
|
||||
let synced = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const [user, mail] = line.split('|')
|
||||
const su = user.replace(/'/g, "''")
|
||||
const sm = (mail || '').replace(/'/g, "''")
|
||||
|
||||
for (const site of SITES) {
|
||||
try {
|
||||
await e(
|
||||
`sqlite3 "${site.db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
synced++
|
||||
console.log(` ${user} → ${mail}`)
|
||||
}
|
||||
console.log(`已同步 ${synced} 个用户邮箱`)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err.message); process.exit(1) })
|
||||
|
|
@ -6,6 +6,26 @@ import RoleManager from './role-manager'
|
|||
|
||||
interface Role { name: string; display_name: string }
|
||||
interface LdapUser { username: string; email: string; displayName: string; createdAt: string }
|
||||
|
||||
const s = {
|
||||
wrap: { minHeight: '100vh', background: 'var(--bg)' } as React.CSSProperties,
|
||||
main: { padding: 'clamp(16px, 3vw, 48px)' } as React.CSSProperties,
|
||||
tabBar: { display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', marginBottom: 32, overflowX: 'auto' as any, whiteSpace: 'nowrap' as any } as React.CSSProperties,
|
||||
content: {} as React.CSSProperties,
|
||||
grid2: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 20 } as React.CSSProperties,
|
||||
tableWrap: { overflowX: 'auto' as any } as React.CSSProperties,
|
||||
field: { marginBottom: 20 } as React.CSSProperties,
|
||||
label: { display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 } as React.CSSProperties,
|
||||
input: { width: '100%', height: 44, padding: '0 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-card)', color: 'var(--text)', fontSize: 14, outline: 'none', boxSizing: 'border-box' as any },
|
||||
select: { width: '100%', height: 44, padding: '0 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-card)', color: 'var(--text)', fontSize: 14, outline: 'none', cursor: 'pointer', boxSizing: 'border-box' as any },
|
||||
btn: { width: '100%', height: 46, background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600, cursor: 'pointer' },
|
||||
table: { width: '100%', borderCollapse: 'collapse' as any } as React.CSSProperties,
|
||||
th: { textAlign: 'left' as any, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '10px 12px', borderBottom: '2px solid var(--border)', textTransform: 'uppercase' as any, letterSpacing: '0.05em' } as React.CSSProperties,
|
||||
td: { padding: '14px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, color: 'var(--text)' } as React.CSSProperties,
|
||||
toast: { position: 'fixed', bottom: 24, right: 24, zIndex: 100, padding: '12px 20px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxShadow: '0 4px 24px rgba(0,0,0,0.12)', animation: 'slideUp 0.3s ease', maxWidth: 400 } as React.CSSProperties,
|
||||
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 10, fontSize: 11, fontWeight: 500 } as React.CSSProperties,
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [tab, setTab] = useState<'create' | 'manage' | 'roles'>('create')
|
||||
const [username, setUsername] = useState('')
|
||||
|
|
@ -13,8 +33,10 @@ export default function AdminUsersPage() {
|
|||
const [email, setEmail] = useState('')
|
||||
const [assetsRole, setAssetsRole] = useState('viewer')
|
||||
const [issueRole, setIssueRole] = useState('viewer')
|
||||
const [monitorRole, setMonitorRole] = useState('viewer')
|
||||
const [assetsRoles, setAssetsRoles] = useState<Role[]>([])
|
||||
const [issueRoles, setIssueRoles] = useState<Role[]>([])
|
||||
const [monitorRoles, setMonitorRoles] = useState<Role[]>([])
|
||||
const [users, setUsers] = useState<LdapUser[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
|
|
@ -22,8 +44,7 @@ export default function AdminUsersPage() {
|
|||
const [loginUser, setLoginUser] = useState('')
|
||||
const [loginDisplayName, setLoginDisplayName] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
// 密码弹窗
|
||||
const [syncingEmails, setSyncingEmails] = useState(false)
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [generatedPwd, setGeneratedPwd] = useState('')
|
||||
const [pwdUser, setPwdUser] = useState('')
|
||||
|
|
@ -38,6 +59,7 @@ export default function AdminUsersPage() {
|
|||
])
|
||||
if (rolesR.assets?.length) setAssetsRoles(rolesR.assets)
|
||||
if (rolesR.issue?.length) setIssueRoles(rolesR.issue)
|
||||
if (rolesR.monitor?.length) setMonitorRoles(rolesR.monitor)
|
||||
if (usersR.users?.length) setUsers(usersR.users)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
|
@ -46,62 +68,36 @@ export default function AdminUsersPage() {
|
|||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
const d = await res.json()
|
||||
if (d.user) {
|
||||
setLoginUser(d.user.username || '')
|
||||
setLoginDisplayName(d.user.displayName || d.user.username || '')
|
||||
setIsAdmin(d.user.isAdmin || false)
|
||||
}
|
||||
if (d.user) { setLoginUser(d.user.username || ''); setLoginDisplayName(d.user.displayName || d.user.username || ''); setIsAdmin(d.user.isAdmin || false) }
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchRoles(); fetchLoginUser() }, [fetchRoles, fetchLoginUser])
|
||||
|
||||
async function refreshUsers() {
|
||||
try {
|
||||
const u = await fetch('/api/admin/users').then(r => r.json())
|
||||
if (u.users?.length) setUsers(u.users)
|
||||
} catch {}
|
||||
try { const u = await fetch('/api/admin/users').then(r => r.json()); if (u.users?.length) setUsers(u.users) } catch {}
|
||||
}
|
||||
|
||||
function showResult(ok: boolean, msg: string) { setResult({ ok, msg }); setTimeout(() => setResult(null), 4000) }
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setResult(null); setLoading(true)
|
||||
setLoading(true)
|
||||
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()
|
||||
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) }
|
||||
setUsername(''); setDisplayName(''); setEmail('')
|
||||
refreshUsers()
|
||||
}
|
||||
setResult({ ok: res.ok, msg: d.message || d.error || '操作完成' })
|
||||
} catch { setResult({ ok: false, msg: '网络错误' }) }
|
||||
showResult(res.ok, d.message || d.error || '操作完成')
|
||||
} catch { showResult(false, '网络错误') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const text = `您好,${pwdName}:
|
||||
|
||||
您的 OA 统一门户账号已创建,请使用以下信息登录:
|
||||
|
||||
用户名:${pwdUser}
|
||||
密 码:${generatedPwd}
|
||||
|
||||
登录地址:https://oa.tlyq.ai
|
||||
|
||||
⚠ 请注意:
|
||||
修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。
|
||||
请登录 OA 后在个人资料页修改密码。
|
||||
|
||||
请在首次登录后及时修改密码。
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
如有疑问,请联系管理员:gxp@qx002575.com`
|
||||
const text = `您好,${pwdName}:\n\n您的 OA 统一门户账号已创建,请使用以下信息登录:\n\n 用户名:${pwdUser}\n 密 码:${generatedPwd}\n\n登录地址:https://oa.tlyq.ai\n\n请在首次登录后及时修改密码。`
|
||||
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch {}
|
||||
}
|
||||
|
||||
|
|
@ -109,157 +105,175 @@ export default function AdminUsersPage() {
|
|||
if (!confirm(`确定删除用户「${target}」?\n\n将从 LLDAP 及所有站点中永久删除,不可撤销。`)) return
|
||||
setDeleting(target)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: target }),
|
||||
})
|
||||
const res = await fetch('/api/admin/users', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: target }) })
|
||||
const d = await res.json()
|
||||
setResult({ ok: res.ok, msg: res.ok ? `已删除 ${target}` : (d.error || '删除失败') })
|
||||
if (res.ok) setUsers(users.filter(u => u.username !== target))
|
||||
} catch { setResult({ ok: false, msg: '网络错误' }) }
|
||||
showResult(res.ok, res.ok ? `已删除 ${target}` : (d.error || '删除失败'))
|
||||
} catch { showResult(false, '网络错误') }
|
||||
finally { setDeleting(null) }
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
async function handleSyncEmails() {
|
||||
setSyncingEmails(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/sync-emails', { method: 'POST' })
|
||||
const d = await res.json()
|
||||
showResult(res.ok, res.ok ? `已同步 ${d.synced} 个用户邮箱至各站点` : (d.error || '同步失败'))
|
||||
} catch { showResult(false, '网络错误') }
|
||||
finally { setSyncingEmails(false) }
|
||||
}
|
||||
const tabStyle = (t: string): React.CSSProperties => ({
|
||||
padding: '10px 20px', border: 'none', background: tab === t ? 'var(--bg-card)' : 'transparent',
|
||||
color: tab === t ? '#2563eb' : 'var(--text-secondary)', cursor: 'pointer',
|
||||
fontSize: 14, fontWeight: tab === t ? 600 : 400,
|
||||
borderBottom: tab === t ? '2px solid #2563eb' : '2px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
})
|
||||
|
||||
const tabItem = (t: string, label: string) => (
|
||||
<button onClick={() => { setTab(t as any); setResult(null) }} style={{
|
||||
padding: '14px 28px', border: 'none', background: 'transparent',
|
||||
color: tab === t ? '#2563eb' : 'var(--text-secondary)',
|
||||
fontSize: 14, fontWeight: tab === t ? 700 : 500, cursor: 'pointer',
|
||||
borderBottom: tab === t ? '3px solid #2563eb' : '3px solid transparent',
|
||||
marginBottom: -1, transition: 'all 0.15s',
|
||||
}}>{label}</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
<div style={s.wrap}>
|
||||
<HeaderUI displayName={loginDisplayName || loginUser} isAdmin={isAdmin} backLabel="用户管理" />
|
||||
|
||||
{/* 密码弹窗 */}
|
||||
{showPwd && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }} onClick={() => setShowPwd(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: '36px 40px', boxShadow: '0 20px 60px rgba(0,0,0,0.2)', textAlign: 'center', maxWidth: 420, width: '90%', border: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔑</div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px', color: 'var(--text)' }}>用户创建成功</h2>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 16px' }}>
|
||||
{pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 16px' }}>{pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}</p>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 20px', fontSize: 22, fontWeight: 700, fontFamily: 'monospace', letterSpacing: '0.05em', color: '#2563eb', marginBottom: 8, wordBreak: 'break-all', userSelect: 'all' }}>{generatedPwd}</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: '0 0 20px' }}>初始密码,请妥善保存</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={handleCopy} style={{ flex: 1, height: 42, borderRadius: 8, border: 'none', cursor: 'pointer', background: copied ? '#f0fdf4' : '#2563eb', color: copied ? '#16a34a' : '#fff', fontSize: 14, fontWeight: 500, transition: 'all 0.2s' }}>{copied ? '✓ 已复制' : '复制信息'}</button>
|
||||
<button onClick={handleCopy} style={{ flex: 1, height: 42, borderRadius: 8, border: 'none', cursor: 'pointer', background: copied ? '#f0fdf4' : '#2563eb', color: copied ? '#16a34a' : '#fff', fontSize: 14, fontWeight: 500 }}>{copied ? '✓ 已复制' : '复制信息'}</button>
|
||||
<button onClick={() => setShowPwd(false)} style={{ flex: 1, height: 42, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-card)', color: 'var(--text-secondary)', fontSize: 14, cursor: 'pointer' }}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 60, paddingBottom: 60 }}>
|
||||
<div style={{ width: 680 }}>
|
||||
|
||||
{/* Tab */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 0 }}>
|
||||
<button onClick={() => { setTab('create'); setResult(null) }} style={tabStyle('create')}>创建用户</button>
|
||||
<button onClick={() => { setTab('manage'); setResult(null) }} style={tabStyle('manage')}>删除用户</button>
|
||||
<button onClick={() => { setTab('roles'); setResult(null) }} style={tabStyle('roles')}>权限管理</button>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderTop: 'none', borderRadius: '0 0 12px 12px', padding: '28px 30px', boxShadow: '0 1px 3px rgba(0,0,0,0.06)' }}>
|
||||
|
||||
{/* Toast */}
|
||||
{result && (
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, marginBottom: 18, fontSize: 13, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626' }}>
|
||||
<div style={{ ...s.toast, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626', border: result.ok ? '1px solid #bbf7d0' : '1px solid #fecaca' }}>
|
||||
{result.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={s.main}>
|
||||
{/* Tab Bar */}
|
||||
<div style={{ ...s.tabBar, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{tabItem('create', '创建用户')}
|
||||
{tabItem('manage', '删除用户')}
|
||||
{tabItem('roles', '权限管理')}
|
||||
</div>
|
||||
<button onClick={handleSyncEmails} disabled={syncingEmails} style={{
|
||||
padding: '8px 18px', borderRadius: 6, border: '1px solid var(--border)',
|
||||
background: 'var(--bg-card)', color: syncingEmails ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||||
fontSize: 12, cursor: syncingEmails ? 'not-allowed' : 'pointer', fontWeight: 500,
|
||||
marginBottom: -1,
|
||||
}}>{syncingEmails ? '同步中...' : '同步邮箱至站点'}</button>
|
||||
</div>
|
||||
|
||||
<div style={s.content}>
|
||||
{/* ====== 创建用户 ====== */}
|
||||
{tab === 'create' && (
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>用户名</div>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="英文用户名" required style={inputStyle} />
|
||||
<div style={s.grid2}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>用户名</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="英文用户名" required style={s.input} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>显示名</div>
|
||||
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="可选" style={inputStyle} />
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>显示名</label>
|
||||
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="可选,留空同用户名" style={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>邮箱地址 <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>(选填,填写后将密码发送至此邮箱)</span></div>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com" style={inputStyle} />
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>邮箱地址 <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>(选填,填写后将发送密码设置链接至此邮箱)</span></label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com" style={s.input} />
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16, marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 12 }}>各站点角色</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>资产管理</div>
|
||||
<select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 24, marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 16 }}>各站点角色</div>
|
||||
<div style={s.grid2}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>资产管理</label>
|
||||
<select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={s.select}>
|
||||
{assetsRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>工单跟踪</div>
|
||||
<select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>工单跟踪</label>
|
||||
<select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={s.select}>
|
||||
{issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||||
</select>
|
||||
</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>
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb', color: '#fff',
|
||||
border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}>{loading ? '创建中...' : '创建用户'}</button>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 12, textAlign: 'center' }}>密码自动生成,创建成功后弹窗显示</p>
|
||||
<button type="submit" disabled={loading} style={{ ...s.btn, background: loading ? '#93c5fd' : '#2563eb', cursor: loading ? 'not-allowed' : 'pointer' }}>
|
||||
{loading ? '创建中...' : '创建用户'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ====== 用户管理 ====== */}
|
||||
{/* ====== 删除用户 ====== */}
|
||||
{tab === 'manage' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>LLDAP 不支持禁用用户,如需停用请删除。已登录用户在删除后下次操作自动退出。</p>
|
||||
{users.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>加载中...</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>加载中...</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={s.tableWrap}><table style={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={s.th}>用户名</th>
|
||||
<th style={s.th}>显示名</th>
|
||||
<th style={s.th}>邮箱</th>
|
||||
<th style={s.th}>创建时间</th>
|
||||
<th style={{ ...s.th, textAlign: 'right' as any }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<div key={u.username} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{u.username}</span>
|
||||
{u.displayName !== u.username && <span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>{u.displayName}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.createdAt?.substring(0, 10)}</span>
|
||||
<tr key={u.username} style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||
<td style={{ ...s.td, fontWeight: 500 }}>{u.username}</td>
|
||||
<td style={{ ...s.td, color: (u.displayName === u.username || !u.displayName) ? 'var(--text-muted)' : 'var(--text)' }}>
|
||||
{(u.displayName && u.displayName !== u.username) ? u.displayName : '—'}
|
||||
</td>
|
||||
<td style={{ ...s.td, color: u.email ? 'var(--text-secondary)' : 'var(--text-muted)', fontSize: 12 }}>
|
||||
{u.email || '未设置'}
|
||||
</td>
|
||||
<td style={{ ...s.td, color: 'var(--text-muted)', fontSize: 12 }}>{u.createdAt?.substring(0, 19)}</td>
|
||||
<td style={{ ...s.td, textAlign: 'right' as any }}>
|
||||
{(u.username === 'admin' || u.username === 'localadmin') ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', padding: '2px 10px', background: 'var(--bg-hover)', borderRadius: 10 }}>系统保留</span>
|
||||
<span style={{ ...s.badge, background: 'var(--bg-hover)', color: 'var(--text-muted)' }}>系统保留</span>
|
||||
) : (
|
||||
<button onClick={() => handleDelete(u.username)} disabled={deleting === u.username} style={{
|
||||
padding: '4px 14px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
padding: '6px 18px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: deleting === u.username ? '#fecaca' : '#fef2f2',
|
||||
color: deleting === u.username ? '#fca5a5' : '#dc2626',
|
||||
fontSize: 12, fontWeight: 500,
|
||||
fontSize: 12, fontWeight: 500, transition: 'all 0.15s',
|
||||
}}>{deleting === u.username ? '...' : '删除'}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== 角色管理 ====== */}
|
||||
{tab === 'roles' && (
|
||||
<RoleManager
|
||||
inputStyle={inputStyle}
|
||||
setResult={setResult}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{/* ====== 权限管理 ====== */}
|
||||
{tab === 'roles' && <RoleManager setResult={showResult} onUserUpdated={fetchLoginUser} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,163 +4,220 @@ import { useState, useEffect, useCallback } from 'react'
|
|||
|
||||
interface SiteUser { username: string; display_name: string; role: string }
|
||||
interface RoleData {
|
||||
assetsUsers: SiteUser[]; issueUsers: SiteUser[]
|
||||
assetsRoles: string[]; issueRoles: string[]
|
||||
assetsUsers: SiteUser[]; issueUsers: SiteUser[]; monitorUsers: SiteUser[]
|
||||
assetsRoles: string[]; issueRoles: string[]; monitorRoles: string[]
|
||||
emails: Record<string, string>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputStyle: React.CSSProperties
|
||||
setResult: (r: { ok: boolean; msg: string } | null) => void
|
||||
interface Props { setResult: (ok: boolean, msg: string) => void; onUserUpdated?: () => void }
|
||||
|
||||
const ss = {
|
||||
bar: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 } as React.CSSProperties,
|
||||
hint: { fontSize: 13, color: 'var(--text-muted)', margin: 0 } as React.CSSProperties,
|
||||
saveBtn: (active: boolean) => ({ padding: '10px 24px', borderRadius: 8, border: 'none', cursor: active ? 'pointer' : 'not-allowed', background: active ? '#d97706' : 'var(--bg-hover)', color: active ? '#fff' : 'var(--text-muted)', fontSize: 13, fontWeight: 600, transition: 'all 0.2s' } as React.CSSProperties),
|
||||
table: { width: '100%', borderCollapse: 'collapse' as any } as React.CSSProperties,
|
||||
th: { textAlign: 'left' as any, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '10px 12px', borderBottom: '2px solid var(--border)', textTransform: 'uppercase' as any, letterSpacing: '0.05em' } as React.CSSProperties,
|
||||
td: { padding: '12px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, color: 'var(--text)' } as React.CSSProperties,
|
||||
roleSelect: (changed: boolean) => ({ height: 34, padding: '0 10px', borderRadius: 6, fontSize: 12, cursor: 'pointer', border: changed ? '1px solid #d97706' : '1px solid var(--border)', background: changed ? 'rgba(217,119,6,0.06)' : 'var(--bg-card)', color: 'var(--text)', outline: 'none', minWidth: 110 } as React.CSSProperties),
|
||||
emailInput: { height: 30, padding: '0 8px', borderRadius: 5, border: '1px solid var(--border)', background: 'var(--bg-card)', color: 'var(--text)', fontSize: 12, width: 160, outline: 'none' } as React.CSSProperties,
|
||||
miniBtn: (primary: boolean) => ({ padding: '4px 12px', borderRadius: 5, border: primary ? 'none' : '1px solid var(--border)', background: primary ? '#2563eb' : 'var(--bg-card)', color: primary ? '#fff' : 'var(--text-secondary)', fontSize: 11, cursor: 'pointer', fontWeight: 500 } as React.CSSProperties),
|
||||
}
|
||||
|
||||
export default function RoleManager({ inputStyle, setResult }: Props) {
|
||||
export default function RoleManager({ setResult, onUserUpdated }: Props) {
|
||||
const [roleData, setRoleData] = useState<RoleData | null>(null)
|
||||
const [pending, setPending] = useState<Record<string, { site: string; newRole: string }>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editingEmail, setEditingEmail] = useState<string | null>(null)
|
||||
const [editEmailValue, setEditEmailValue] = useState('')
|
||||
const [savingEmail, setSavingEmail] = useState(false)
|
||||
const [editingDisplayName, setEditingDisplayName] = useState<string | null>(null)
|
||||
const [editDisplayNameValue, setEditDisplayNameValue] = useState('')
|
||||
const [savingDisplayName, setSavingDisplayName] = useState(false)
|
||||
const [syncing, setSyncing] = useState<string | null>(null) // username being synced, or 'all'
|
||||
|
||||
const fetchRoleData = useCallback(async () => {
|
||||
try {
|
||||
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 || [],
|
||||
})
|
||||
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 || [], monitorUsers: d.users.monitor || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], monitorRoles: d.monitorRoles || [], emails: d.emails || {} })
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchRoleData() }, [fetchRoleData])
|
||||
|
||||
function handleSelect(site: string, username: string, newRole: string, originalRole: string) {
|
||||
if (newRole === originalRole) {
|
||||
// 改回原值,清除 pending
|
||||
const next = { ...pending }
|
||||
delete next[`${site}:${username}`]
|
||||
setPending(next)
|
||||
} else {
|
||||
setPending({ ...pending, [`${site}:${username}`]: { site, newRole } })
|
||||
async function handleEditEmail(target: string) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editEmailValue)) { setResult(false, '邮箱格式不合法'); return }
|
||||
setSavingEmail(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: target, email: editEmailValue }) })
|
||||
const d = await res.json()
|
||||
if (res.ok) {
|
||||
setRoleData(prev => prev ? { ...prev, emails: { ...prev.emails, [target]: d.email } } : null)
|
||||
setEditingEmail(null); setResult(true, `${target} 邮箱已更新`)
|
||||
} else { setResult(false, d.error || '修改失败') }
|
||||
} catch { setResult(false, '网络错误') }
|
||||
finally { setSavingEmail(false) }
|
||||
}
|
||||
|
||||
async function handleEditDisplayName(target: string) {
|
||||
if (!editDisplayNameValue.trim()) { setResult(false, '显示名不能为空'); return }
|
||||
setSavingDisplayName(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: target, displayName: editDisplayNameValue.trim() }) })
|
||||
const d = await res.json()
|
||||
if (res.ok) {
|
||||
setRoleData(prev => prev ? {
|
||||
...prev,
|
||||
assetsUsers: prev.assetsUsers.map(u => u.username === target ? { ...u, display_name: d.displayName } : u),
|
||||
issueUsers: prev.issueUsers.map(u => u.username === target ? { ...u, display_name: d.displayName } : u),
|
||||
} : null)
|
||||
setEditingDisplayName(null); setResult(true, `${target} 显示名已更新`); onUserUpdated?.()
|
||||
} else { setResult(false, d.error || '修改失败') }
|
||||
} catch { setResult(false, '网络错误') }
|
||||
finally { setSavingDisplayName(false) }
|
||||
}
|
||||
|
||||
function handleSelect(site: string, username: string, newRole: string, originalRole: string) {
|
||||
if (newRole === originalRole) { const next = { ...pending }; delete next[`${site}:${username}`]; setPending(next) }
|
||||
else { setPending({ ...pending, [`${site}:${username}`]: { site, newRole } }) }
|
||||
}
|
||||
|
||||
function getCurrentRole(site: string, username: string, originalRole: string): string {
|
||||
const key = `${site}:${username}`
|
||||
return pending[key]?.newRole || originalRole
|
||||
return pending[`${site}:${username}`]?.newRole || originalRole
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(pending).length === 0) return
|
||||
if (!confirm(`确定保存 ${Object.keys(pending).length} 项角色修改?`)) return
|
||||
|
||||
setSaving(true)
|
||||
let ok = 0; let fail = 0
|
||||
setSaving(true); let ok = 0; let fail = 0
|
||||
for (const [key, { site, newRole }] of Object.entries(pending)) {
|
||||
const username = key.split(':')[1]
|
||||
try {
|
||||
const res = await fetch('/api/admin/user-roles', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, site, role: newRole }),
|
||||
})
|
||||
const res = await fetch('/api/admin/user-roles', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, site, role: newRole }) })
|
||||
if (res.ok) ok++; else fail++
|
||||
} catch { fail++ }
|
||||
}
|
||||
|
||||
setResult({ ok: fail === 0, msg: `已保存:${ok} 项成功${fail > 0 ? `,${fail} 项失败` : ''}` })
|
||||
setPending({})
|
||||
if (fail === 0) fetchRoleData()
|
||||
setSaving(false)
|
||||
setResult(fail === 0, `已保存:${ok} 项成功${fail > 0 ? `,${fail} 项失败` : ''}`)
|
||||
setPending({}); if (fail === 0) fetchRoleData(); setSaving(false)
|
||||
}
|
||||
|
||||
if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>加载中...</p>
|
||||
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) }
|
||||
}
|
||||
|
||||
// 合并两个站点用户
|
||||
const userMap = new Map<string, { displayName: string; assetsRole: string; issueRole: string }>()
|
||||
roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, assetsRole: u.role, issueRole: '—' }))
|
||||
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, assetsRole: '—', issueRole: u.role })
|
||||
})
|
||||
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; monitorRole: string }>()
|
||||
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, 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 changed = Object.keys(pending).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
修改角色后点击「保存修改」统一提交
|
||||
{changed > 0 && <span style={{ color: '#d97706', fontWeight: 600, marginLeft: 8 }}>({changed} 项待保存)</span>}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={changed === 0 || saving}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 8, border: 'none', cursor: changed > 0 ? 'pointer' : 'not-allowed',
|
||||
background: changed > 0 ? '#d97706' : 'var(--bg-hover)',
|
||||
color: changed > 0 ? '#fff' : 'var(--text-muted)',
|
||||
fontSize: 13, fontWeight: 500, transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={ss.bar}>
|
||||
<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)}>
|
||||
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12, padding: '0 0 10px', borderBottom: '2px solid var(--border)', fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
<div>用户名</div><div>显示名</div><div>资产管理</div><div>工单跟踪</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}><table style={ss.table}>
|
||||
<thead>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(([uname, info]) => (
|
||||
<div key={uname} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12, alignItems: 'center', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{uname}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{info.displayName}</div>
|
||||
{/* 资产管理 */}
|
||||
<RoleCell
|
||||
site="assets" username={uname} originalRole={info.assetsRole}
|
||||
roles={roleData.assetsRoles} pending={pending}
|
||||
inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
|
||||
/>
|
||||
{/* 工单跟踪 */}
|
||||
<RoleCell
|
||||
site="issue" username={uname} originalRole={info.issueRole}
|
||||
roles={roleData.issueRoles} pending={pending}
|
||||
inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
|
||||
/>
|
||||
</div>
|
||||
<tr key={uname} style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||
<td style={{ ...ss.td, fontWeight: 500 }}>{uname}</td>
|
||||
<td style={ss.td}>
|
||||
{editingDisplayName === uname ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input value={editDisplayNameValue} onChange={e => setEditDisplayNameValue(e.target.value)} style={ss.emailInput} />
|
||||
<button onClick={() => handleEditDisplayName(uname)} disabled={savingDisplayName} style={ss.miniBtn(true)}>{savingDisplayName ? '..' : '保存'}</button>
|
||||
<button onClick={() => setEditingDisplayName(null)} style={ss.miniBtn(false)}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: info.displayName === uname ? 'var(--text-muted)' : 'var(--text)' }}>
|
||||
{info.displayName}
|
||||
{(uname !== 'admin' && uname !== 'localadmin') && (
|
||||
<button onClick={() => { setEditingDisplayName(uname); setEditDisplayNameValue(info.displayName) }} style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: 'pointer', padding: '0 0 0 6px' }}>编辑</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={ss.td}>
|
||||
{editingEmail === uname ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="email" value={editEmailValue} onChange={e => setEditEmailValue(e.target.value)} placeholder="user@example.com" style={ss.emailInput} />
|
||||
<button onClick={() => handleEditEmail(uname)} disabled={savingEmail} style={ss.miniBtn(true)}>{savingEmail ? '..' : '保存'}</button>
|
||||
<button onClick={() => setEditingEmail(null)} style={ss.miniBtn(false)}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: info.email ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
|
||||
{info.email || '未设置'}
|
||||
{(uname !== 'admin' && uname !== 'localadmin') && (
|
||||
<button onClick={() => { setEditingEmail(uname); setEditEmailValue(info.email || '') }} style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: 'pointer', padding: '0 0 0 6px' }}>编辑</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</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} 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>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleCell({ site, username, originalRole, roles, pending, inputStyle, onSelect, getCurrentRole }: {
|
||||
function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole, syncing, onSync }: {
|
||||
site: string; username: string; originalRole: string; roles: string[]
|
||||
pending: Record<string, { site: string; newRole: string }>
|
||||
inputStyle: React.CSSProperties
|
||||
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
|
||||
getCurrentRole: (site: string, username: string, originalRole: string) => string
|
||||
syncing: string | null; onSync: (usernames: string[], site?: string) => void
|
||||
}) {
|
||||
const isReserved = username === 'admin' || username === 'localadmin'
|
||||
const key = `${site}:${username}`
|
||||
const changed = !!pending[key]
|
||||
const changed = !!pending[`${site}:${username}`]
|
||||
const currentRole = getCurrentRole(site, username, originalRole)
|
||||
|
||||
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 (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</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 (
|
||||
<select
|
||||
value={currentRole}
|
||||
onChange={e => onSelect(site, username, e.target.value, originalRole)}
|
||||
style={{
|
||||
...inputStyle, height: 34, fontSize: 12, cursor: 'pointer', appearance: 'auto' as any,
|
||||
width: 'auto', minWidth: 110,
|
||||
border: changed ? '1px solid #d97706' : '1px solid var(--border)',
|
||||
background: changed ? 'rgba(217,119,6,0.06)' : 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
<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>)}
|
||||
</select>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { sendCredentialsEmail } from '@/lib/email'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { sendSetupLinkEmail } from '@/lib/email'
|
||||
import { signSetupToken } from '@/lib/setup-token'
|
||||
import { execLldap, lldapChangePassword, esc, getAdminPassword } from '@/lib/lldap-db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
|
|
@ -18,10 +18,8 @@ function generatePassword(): string {
|
|||
const all = upper + lower + digits + special
|
||||
const crypto = globalThis.crypto
|
||||
const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length]
|
||||
// 确保每种类型至少一个,其余随机填充到 12 位
|
||||
let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special)
|
||||
for (let i = 4; i < 12; i++) pwd += pick(all)
|
||||
// 打乱顺序
|
||||
return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('')
|
||||
}
|
||||
|
||||
|
|
@ -33,28 +31,18 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|||
})
|
||||
const data = await res.json()
|
||||
return (data.roles || []).map((r: { name: string }) => r.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${siteUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 直接更新站点 SQLite 数据库中的用户角色
|
||||
function setRoleSQL(dbPath: string, username: string, role: string): string {
|
||||
return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"`
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -67,78 +55,68 @@ export async function POST(request: Request) {
|
|||
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 (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 })
|
||||
|
||||
const password = generatePassword()
|
||||
|
||||
// 从各站点实时获取可用角色列表
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
const ASSETS_URL = process.env.ASSETS_INTERNAL_URL || 'http://localhost:6177'
|
||||
const ISSUE_URL = process.env.ISSUE_INTERNAL_URL || 'http://localhost:6176'
|
||||
const MONITOR_URL = process.env.MONITOR_INTERNAL_URL || 'http://localhost:6181'
|
||||
const [assetsRoles, issueRoles, monitorRoles] = await Promise.all([
|
||||
fetchRoles(ASSETS_URL), fetchRoles(ISSUE_URL), fetchRoles(MONITOR_URL),
|
||||
])
|
||||
|
||||
const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer'
|
||||
const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer'
|
||||
const mr = (monitorRole && monitorRoles.includes(monitorRole)) ? monitorRole : 'viewer'
|
||||
|
||||
const safeName = (displayName || username).replace(/'/g, "'\\''")
|
||||
const safeUser = username.replace(/'/g, "'\\''")
|
||||
const lldapEmail = `${username}@tlyq.ai`
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
const safeName = esc(displayName || username)
|
||||
const safeUser = esc(username)
|
||||
const lldapEmail = email || ''
|
||||
const d = new Date()
|
||||
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
|
||||
const userUuid = crypto.randomUUID()
|
||||
|
||||
// 1. LLDAP SQLite 插入用户
|
||||
const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');`
|
||||
await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 })
|
||||
// 1. docker exec lldap 插入用户(LLDAP DELETE 模式不可并发写)
|
||||
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}')`)
|
||||
|
||||
// 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
||||
|
||||
// 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题
|
||||
const b64Pass = Buffer.from(password).toString('base64')
|
||||
await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 })
|
||||
const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'`
|
||||
await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 })
|
||||
// 2. bcryptjs 直写 LLDAP 密码(替代 docker exec lldap_set_password)
|
||||
lldapChangePassword(username, password)
|
||||
|
||||
// 3. 自动登录各站点触发用户同步
|
||||
const [assetsOk, issueOk] = await Promise.all([
|
||||
syncToSite('http://localhost:6177', username, password),
|
||||
syncToSite('http://localhost:6176', username, password),
|
||||
const [assetsOk, issueOk, monitorOk] = await Promise.all([
|
||||
syncToSite(ASSETS_URL, username, password),
|
||||
syncToSite(ISSUE_URL, username, password),
|
||||
syncToSite(MONITOR_URL, username, password),
|
||||
])
|
||||
|
||||
// 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值)
|
||||
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
|
||||
const roleResults = { assets: false, issue: false }
|
||||
if (assetsOk) {
|
||||
try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {}
|
||||
}
|
||||
if (issueOk) {
|
||||
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
|
||||
}
|
||||
// 4. 直接更新各站点角色
|
||||
const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
|
||||
const monitorDb = process.env.MONITOR_DB_PATH || '/data/other-sites/monitor/monitor.db'
|
||||
const roleResults = { assets: false, issue: false, monitor: false }
|
||||
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 (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 {}
|
||||
|
||||
// 5. 如果提供了邮箱,发送凭证邮件
|
||||
// 5. 如果提供了邮箱,发送密码设置链接
|
||||
let emailSent = false
|
||||
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
try {
|
||||
await sendCredentialsEmail(email, username, password, displayName || username)
|
||||
const setupToken = signSetupToken(username)
|
||||
await sendSetupLinkEmail(email, username, `https://oa.tlyq.ai/setup-password?token=${setupToken}`, displayName || username)
|
||||
emailSent = true
|
||||
} catch (e) {
|
||||
console.error('发送邮件失败:', e)
|
||||
}
|
||||
} catch (e) { console.error('发送邮件失败:', e) }
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
password: emailSent ? undefined : password,
|
||||
synced: { assets: assetsOk, issue: issueOk },
|
||||
roles: { assets: ar, issue: ir, applied: roleResults },
|
||||
synced: { assets: assetsOk, issue: issueOk, monitor: monitorOk },
|
||||
roles: { assets: ar, issue: ir, monitor: mr, applied: roleResults },
|
||||
emailSent,
|
||||
message: emailSent
|
||||
? `用户已创建,密码已发送至 ${email}`
|
||||
: '用户已创建并同步至所有站点',
|
||||
message: emailSent ? `用户已创建,密码设置链接已发送至 ${email}` : '用户已创建并同步至所有站点',
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '创建失败'
|
||||
|
|
|
|||
|
|
@ -5,14 +5,24 @@ import { isLldapAdmin } from '@/lib/ldap'
|
|||
|
||||
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 {
|
||||
const res = await fetch(`${url}/api/internal/roles`, {
|
||||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
||||
headers: { 'x-internal-key': INTERNAL_KEY },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.roles || []
|
||||
return (data.roles || []).map((r: { name: string }) => ({
|
||||
name: r.name,
|
||||
display_name: ROLE_LABELS[r.name] || r.name,
|
||||
}))
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
|
|
@ -23,10 +33,13 @@ export async function GET() {
|
|||
const session = verifySharedJwt(token)
|
||||
if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
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 [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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
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 ASSETS_DB = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const ISSUE_DB = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
const session = verifySharedJwt(token)
|
||||
if (!session || !(await isLldapAdmin(session.username))) {
|
||||
return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 })
|
||||
}
|
||||
|
||||
const out = queryLldap(`SELECT user_id, email FROM users WHERE email != ''`)
|
||||
const lines = out.split('\n').filter(Boolean)
|
||||
let synced = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const [user, mail] = line.split('|')
|
||||
const su = esc(user)
|
||||
const sm = esc(mail || '')
|
||||
for (const db of [ASSETS_DB, ISSUE_DB]) {
|
||||
try {
|
||||
execFileSync('sqlite3', [db], {
|
||||
input: `UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';`,
|
||||
timeout: 3000,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, synced })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { queryLldap } from '@/lib/lldap-db'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||||
|
||||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
||||
|
|
@ -19,13 +18,13 @@ async function fetchRoles(siteUrl: string): Promise<string[]> {
|
|||
} catch { return [] }
|
||||
}
|
||||
|
||||
function queryDb(dbPath: string, sql: string): Promise<string> {
|
||||
return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '')
|
||||
function queryDb(dbPath: string, sql: string): string {
|
||||
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 }[]> {
|
||||
const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
|
||||
return out.trim().split('\n').filter(Boolean).map(line => {
|
||||
const out = queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;')
|
||||
return out.split('\n').filter(Boolean).map(line => {
|
||||
const [username, display_name, role] = line.split('|')
|
||||
return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' }
|
||||
})
|
||||
|
|
@ -39,27 +38,60 @@ async function checkAdmin() {
|
|||
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 — 列出各站点用户及其角色
|
||||
export async function GET() {
|
||||
if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const [assetsRoles, issueRoles] = await Promise.all([
|
||||
fetchRoles('http://localhost:6177'),
|
||||
fetchRoles('http://localhost:6176'),
|
||||
const [assetsRoles, issueRoles, monitorRoles] = await Promise.all([
|
||||
fetchRoles(A_URL), fetchRoles(I_URL), fetchRoles(M_URL),
|
||||
])
|
||||
|
||||
// monitor 走 API(DB 被 monitor 进程锁,不可直连),assets/issue 直连 SQLite
|
||||
const [assetsUsers, issueUsers] = await Promise.all([
|
||||
getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles),
|
||||
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles),
|
||||
getSiteUsers(A_DB, assetsRoles),
|
||||
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 不可达时使用空列表 */ }
|
||||
|
||||
let emails: Record<string, string> = {}
|
||||
try {
|
||||
const out = queryLldap(`SELECT user_id, email FROM users`)
|
||||
out.split('\n').filter(Boolean).forEach(line => {
|
||||
const [uid, e] = line.split('|')
|
||||
emails[uid] = e || ''
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
assetsRoles,
|
||||
issueRoles,
|
||||
users: { assets: assetsUsers, issue: issueUsers },
|
||||
assetsRoles, issueRoles, monitorRoles,
|
||||
users: { assets: assetsUsers, issue: issueUsers, monitor: monitorUsers },
|
||||
emails,
|
||||
})
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -73,18 +105,22 @@ export async function PUT(request: Request) {
|
|||
if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
||||
if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 })
|
||||
|
||||
const dbPath = site === 'assets'
|
||||
? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db')
|
||||
: (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db')
|
||||
|
||||
// 验证角色合法性
|
||||
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
|
||||
const roles = await fetchRoles(siteUrl(site))
|
||||
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
|
||||
|
||||
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now') 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 })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '更新失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { queryLldap, execLldap, esc } from '@/lib/lldap-db'
|
||||
|
||||
function checkAdmin() {
|
||||
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 中所有用户
|
||||
export async function GET() {
|
||||
const isAdmin = await checkAdmin()()
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
const users = stdout.trim().split('\n').filter(Boolean).map(line => {
|
||||
const out = queryLldap(`SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC`)
|
||||
const users = out.split('\n').filter(Boolean).map(line => {
|
||||
const [user_id, email, display_name, creation_date] = line.split('|')
|
||||
return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date }
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -49,29 +53,62 @@ export async function DELETE(request: Request) {
|
|||
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> = {}
|
||||
for (const [site, dbPath] of Object.entries({
|
||||
assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db',
|
||||
issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db',
|
||||
assets: process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db',
|
||||
issue: process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db',
|
||||
})) {
|
||||
try {
|
||||
await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 })
|
||||
results[site] = true
|
||||
} catch { results[site] = false }
|
||||
try { siteSQL(dbPath, `DELETE FROM users WHERE username='${safeUser}'`); results[site] = true } catch { results[site] = false }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, deleted: results })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: '删除失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — 修改用户信息
|
||||
export async function PATCH(request: Request) {
|
||||
const isAdmin = await checkAdmin()()
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const { username, email, displayName } = await request.json()
|
||||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
||||
if (email === undefined && displayName === undefined) {
|
||||
return NextResponse.json({ error: '至少需要 email 或 displayName' }, { status: 400 })
|
||||
}
|
||||
if (email !== undefined && email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeUser = esc(username)
|
||||
let lldapSets: string[] = [], siteSets: string[] = []
|
||||
if (email !== undefined) {
|
||||
const safeEmail = esc(email || '')
|
||||
lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`)
|
||||
siteSets.push(`email = '${safeEmail}'`)
|
||||
}
|
||||
if (displayName !== undefined) {
|
||||
const safeName = esc(displayName)
|
||||
lldapSets.push(`display_name = '${safeName}'`)
|
||||
siteSets.push(`display_name = '${safeName}'`)
|
||||
}
|
||||
lldapSets.push(`modified_date = '${nowStr()}'`)
|
||||
siteSets.push(`updated_at = datetime('now', '+8 hours')`)
|
||||
|
||||
execLldap(`UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}'`)
|
||||
|
||||
const siteSql = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}'`
|
||||
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']) {
|
||||
siteSQL(dbPath, siteSql)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, username, email, displayName })
|
||||
} catch {
|
||||
return NextResponse.json({ error: '修改失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session)
|
||||
import { NextRequest } from 'next/server'
|
||||
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
||||
import { ldapUserExists } from '@/lib/ldap'
|
||||
import { syncUserToAllSites } from '@/lib/sync-user'
|
||||
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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
|
||||
},
|
||||
|
||||
// OA 不通过 OIDC 创建/更新用户
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import { lldapChangePassword, getAdminPassword } from '@/lib/lldap-db'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
|
@ -22,7 +19,6 @@ export async function POST(request: Request) {
|
|||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 })
|
||||
}
|
||||
// 密码复杂度:大写/小写/数字/特殊字符 4选3
|
||||
const hasUpper = /[A-Z]/.test(newPassword)
|
||||
const hasLower = /[a-z]/.test(newPassword)
|
||||
const hasDigit = /[0-9]/.test(newPassword)
|
||||
|
|
@ -32,25 +28,12 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置)
|
||||
const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 })
|
||||
const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''")
|
||||
|
||||
const safeUser = session.username.replace(/'/g, "'\\''")
|
||||
const safePass = newPassword.replace(/'/g, "'\\''")
|
||||
const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'`
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 })
|
||||
if (stderr && !stderr.includes('Successfully')) {
|
||||
return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 })
|
||||
}
|
||||
// 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec)
|
||||
lldapChangePassword(session.username, newPassword)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '修改失败'
|
||||
if (msg.includes('command not found') || msg.includes('No such container')) {
|
||||
return NextResponse.json({ error: '密码服务不可用' }, { status: 503 })
|
||||
}
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
||||
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
||||
|
||||
const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const switchUser = url.searchParams.get('switch') === '1'
|
||||
return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser })
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
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) {
|
||||
try {
|
||||
|
|
@ -23,6 +25,20 @@ export async function POST(request: Request) {
|
|||
const cookieStore = await cookies()
|
||||
cookieStore.set(cfg.name, token, cfg)
|
||||
|
||||
// 审计日志
|
||||
try {
|
||||
writeAuditLog({
|
||||
username: result.username!,
|
||||
action: 'login',
|
||||
details: { method: 'ldap', displayName: result.displayName },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown',
|
||||
})
|
||||
} catch { /* 审计日志失败不影响登录 */ }
|
||||
|
||||
// 跨站点角色同步(不阻塞响应)
|
||||
const role = (await isLldapAdmin(result.username!)) ? 'admin' : 'viewer'
|
||||
syncUserToAllSites(result.username!, result.displayName!, role).catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
user: { username: result.username, displayName: result.displayName },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/' })
|
||||
return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://localhost:6179'))
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
/** 从 OIDC_REDIRECT_URI 提取 site URL(不可信请求头,见 LESSONS-LEARNED #51) */
|
||||
function getSiteUrl(): string {
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
|
||||
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'
|
||||
}
|
||||
|
||||
/** 清除 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() }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ import { NextResponse } from 'next/server'
|
|||
import { cookies } from 'next/headers'
|
||||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import { isLldapAdmin } from '@/lib/ldap'
|
||||
import { queryLldap, execLldap, esc } from '@/lib/lldap-db'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> {
|
||||
try {
|
||||
const safe = esc(username)
|
||||
const out = queryLldap(`SELECT email, display_name FROM users WHERE user_id = '${safe}'`)
|
||||
const parts = out.split('|')
|
||||
return { email: parts[0] || '', displayName: parts[1] || username }
|
||||
} catch { return { email: '', displayName: username } }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
|
@ -12,12 +23,56 @@ export async function GET() {
|
|||
const payload = verifySharedJwt(token)
|
||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||
|
||||
const admin = await isLldapAdmin(payload.username)
|
||||
const [admin, info] = await Promise.all([
|
||||
isLldapAdmin(payload.username),
|
||||
getLldapInfo(payload.username),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
user: { username: payload.username, displayName: payload.displayName, isAdmin: admin },
|
||||
user: { username: payload.username, displayName: info.displayName, email: info.email, isAdmin: admin },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const payload = verifySharedJwt(token)
|
||||
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
|
||||
|
||||
const { email } = await request.json()
|
||||
if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeUser = esc(payload.username)
|
||||
const safeEmail = 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')}`
|
||||
|
||||
// docker exec lldap 更新邮箱(LLDAP DELETE 模式不可并发写)
|
||||
execLldap(`UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}'`)
|
||||
|
||||
// 同步更新 assets / issue 本地用户表
|
||||
const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db'
|
||||
const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db'
|
||||
for (const dbPath of [assetsDb, issueDb]) {
|
||||
try {
|
||||
execFileSync('sqlite3', [dbPath], {
|
||||
input: `UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`,
|
||||
timeout: 3000,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, email: email || '' })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '修改失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { verifySetupToken } from '@/lib/setup-token'
|
||||
import { lldapChangePassword } from '@/lib/lldap-db'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { token, password } = await request.json()
|
||||
if (!token || !password) {
|
||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = verifySetupToken(token)
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 })
|
||||
}
|
||||
const hasUpper = /[A-Z]/.test(password)
|
||||
const hasLower = /[a-z]/.test(password)
|
||||
const hasDigit = /[0-9]/.test(password)
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(password)
|
||||
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
||||
if (score < 3) {
|
||||
return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec)
|
||||
lldapChangePassword(payload.username, password)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '设置失败'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'OK' })
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
|
||||
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
|
||||
}
|
||||
.dark {
|
||||
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
|
||||
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import './globals.css'
|
||||
|
||||
export const metadata = { title: 'OA 统一门户' }
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -13,24 +15,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
} catch(e) {}
|
||||
})();
|
||||
`}} />
|
||||
<style>{`
|
||||
:root {
|
||||
--bg: #f8fafc; --bg-card: #fff; --bg-hover: #f1f5f9; --border: #e2e8f0;
|
||||
--text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8;
|
||||
}
|
||||
.dark {
|
||||
--bg: #020617; --bg-card: #0f172a; --bg-hover: #1e293b; --border: #1e293b;
|
||||
--text: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #64748b;
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body style={{
|
||||
margin: 0,
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text)',
|
||||
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
transition: 'background 0.3s, color 0.3s',
|
||||
}}>
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,82 +1,101 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginPageContent() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showLdapForm, setShowLdapForm] = useState(false)
|
||||
const searchParams = useSearchParams()
|
||||
const isLocalMode = searchParams.get('method') === 'local'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
e.preventDefault(); setError(''); setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json()
|
||||
setError(d.error || '登录失败')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error || '登录失败'); return }
|
||||
window.location.href = '/'
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch { setError('网络错误') } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', height: 42, padding: '0 12px',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
|
||||
fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
|
||||
function handleSsoLogin() {
|
||||
fetch('/api/auth/login/oidc').then(res => {
|
||||
if (res.status === 200) {
|
||||
return res.json().then(data => {
|
||||
if (data.conflict) { window.location.href = '/' }
|
||||
else { setError(data.error || '登录失败') }
|
||||
})
|
||||
}
|
||||
window.location.href = res.url || '/api/auth/login/oidc'
|
||||
}).catch(() => { window.location.href = '/api/auth/login/oidc' })
|
||||
}
|
||||
|
||||
const showLocalForm = isLocalMode || showLdapForm
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||
<div style={{
|
||||
width: 400, background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12,
|
||||
padding: '40px 36px', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{ width: 44, height: 44, margin: '0 auto 14px', background: '#2563eb', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, color: '#fff' }}>◎</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text)', margin: 0 }}>统一门户</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>TLYQ.IDENTITY</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>账号</div>
|
||||
<input type="text" placeholder="LDAP 用户名" value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}>密码</div>
|
||||
<input type="password" placeholder="输入密码" value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onFocus={e => { e.target.style.borderColor = 'transparent'; e.target.style.boxShadow = '0 0 0 2px #2563eb' }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border)'; e.target.style.boxShadow = 'none' }}
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
{error && <p style={{ color: '#dc2626', fontSize: 13, marginBottom: 12, padding: '8px 12px', background: '#fef2f2', borderRadius: 8 }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb',
|
||||
color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 500, cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}>
|
||||
{loading ? '验证中...' : '登 录'}
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 px-4">
|
||||
<div className="w-full max-w-sm bg-white dark:bg-slate-900 rounded-lg border border-blue-200/50 dark:border-blue-500/20 shadow-lg p-8">
|
||||
<h1 className="text-2xl font-bold text-center mb-6 text-slate-900 dark:text-white">统一门户</h1>
|
||||
{error && <div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>}
|
||||
|
||||
{!showLocalForm ? (
|
||||
<>
|
||||
<button onClick={handleSsoLogin} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors duration-200 mb-3">
|
||||
统一认证登录
|
||||
</button>
|
||||
<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 style={{ textAlign: 'center', marginTop: 20, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
通过 <span style={{ color: '#2563eb', fontWeight: 500 }}>LLDAP</span> 统一身份认证
|
||||
</p>
|
||||
<p className="text-center">
|
||||
<button onClick={() => setShowLdapForm(true)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||
使用 LDAP 直接登录
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">用户名</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">密码</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入密码"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="w-full py-2 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200">{loading ? '登录中...' : '登录'}</button>
|
||||
</form>
|
||||
{!isLocalMode && (
|
||||
<p className="text-center mt-3">
|
||||
<button onClick={() => setShowLdapForm(false)} className="text-xs text-slate-400 hover:text-slate-600 underline">
|
||||
返回统一认证登录
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">加载中...</div>}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,20 +11,22 @@ function siteUrl(url: string, domain: string): string {
|
|||
}
|
||||
|
||||
const CORE_SITES = [
|
||||
{ name: '资产管理', url: 'http://localhost:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' },
|
||||
{ name: '工单跟踪', url: 'http://localhost:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.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:6181', desc: '统一告警监控中心,容器/端点健康检查,企业微信告警推送', tag: 'MONITOR', dot: '#4f46e5', domain: 'monitor.tlyq.ai' },
|
||||
]
|
||||
|
||||
const OTHER_SITES = [
|
||||
{ name: '官网', url: 'http://localhost:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
||||
{ name: '云平台', url: 'http://localhost:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
||||
{ name: 'Token 工厂', url: 'http://localhost:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
||||
{ name: '官网', url: 'http://127.0.0.1:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' },
|
||||
{ name: '云平台', url: 'http://127.0.0.1:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' },
|
||||
{ name: 'Token 工厂', url: 'http://127.0.0.1:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' },
|
||||
{ name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' },
|
||||
]
|
||||
|
||||
const COLORS: Record<string, { light: string; tag: string }> = {
|
||||
'#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' },
|
||||
'#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' },
|
||||
'#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' },
|
||||
'#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function EmailEditor() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.user) setEmail(d.user.email || '') })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setMessage(''); setError('')
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
setError('邮箱格式不合法')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: value }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (res.ok) {
|
||||
setEmail(d.email)
|
||||
setMessage('邮箱已更新')
|
||||
setEditing(false)
|
||||
} else {
|
||||
setError(d.error || '修改失败')
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 3 }}>邮箱</div>
|
||||
|
||||
{!editing ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: email ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{email || '未设置'}
|
||||
</span>
|
||||
<button onClick={() => { setValue(email); setEditing(true); setMessage(''); setError('') }} style={{
|
||||
background: 'none', border: 'none', color: '#2563eb', fontSize: 12, cursor: 'pointer', padding: 0,
|
||||
}}>编辑</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
style={{
|
||||
height: 34, padding: '0 10px', borderRadius: 6,
|
||||
border: error ? '1px solid #dc2626' : '1px solid var(--border)',
|
||||
background: 'var(--bg-card)', color: 'var(--text)',
|
||||
fontSize: 13, outline: 'none', flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleSave} disabled={saving} style={{
|
||||
height: 34, padding: '0 14px', borderRadius: 6, border: 'none',
|
||||
background: saving ? '#93c5fd' : '#2563eb', color: '#fff',
|
||||
fontSize: 12, fontWeight: 500, cursor: saving ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{saving ? '...' : '保存'}</button>
|
||||
<button onClick={() => { setEditing(false); setError('') }} style={{
|
||||
height: 34, padding: '0 10px', borderRadius: 6,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-card)',
|
||||
color: 'var(--text-secondary)', fontSize: 12, cursor: 'pointer',
|
||||
}}>取消</button>
|
||||
</div>
|
||||
{message && <p style={{ color: '#16a34a', fontSize: 12, marginTop: 4 }}>{message}</p>}
|
||||
{error && <p style={{ color: '#dc2626', fontSize: 12, marginTop: 4 }}>{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'
|
|||
import { verifySharedJwt } from '@/lib/jwt'
|
||||
import Header from '@/components/Header'
|
||||
import ChangePasswordForm from './change-password-form'
|
||||
import EmailEditor from './email-editor'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const cookieStore = await cookies()
|
||||
|
|
@ -38,6 +39,9 @@ export default async function ProfilePage() {
|
|||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{(() => { const d = new Date(session.exp * 1000); 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')}` })()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 18, paddingTop: 18, borderTop: '1px solid var(--border)' }}>
|
||||
<EmailEditor />
|
||||
</div>
|
||||
</div>
|
||||
<ChangePasswordForm />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
'use client'
|
||||
|
||||
import { Suspense, useState, useMemo } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
interface PasswordStrength {
|
||||
minLength: boolean
|
||||
hasUpper: boolean
|
||||
hasLower: boolean
|
||||
hasDigit: boolean
|
||||
hasSpecial: boolean
|
||||
score: number
|
||||
}
|
||||
|
||||
function checkPassword(pw: string): PasswordStrength {
|
||||
const minLength = pw.length >= 8
|
||||
const hasUpper = /[A-Z]/.test(pw)
|
||||
const hasLower = /[a-z]/.test(pw)
|
||||
const hasDigit = /[0-9]/.test(pw)
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw)
|
||||
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
||||
return { minLength, hasUpper, hasLower, hasDigit, hasSpecial, score }
|
||||
}
|
||||
|
||||
const checks: { key: keyof PasswordStrength; label: string }[] = [
|
||||
{ key: 'minLength', label: '至少 8 位字符' },
|
||||
{ key: 'hasUpper', label: '包含大写字母' },
|
||||
{ key: 'hasLower', label: '包含小写字母' },
|
||||
{ key: 'hasDigit', label: '包含数字' },
|
||||
{ key: 'hasSpecial', label: '包含特殊字符' },
|
||||
]
|
||||
|
||||
function SetupPasswordForm() {
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token') || ''
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const strength = useMemo(() => checkPassword(password), [password])
|
||||
const isStrong = strength.minLength && strength.score >= 3
|
||||
const passwordMismatch = confirm.length > 0 && password !== confirm
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!isStrong) {
|
||||
setError('请先满足密码复杂度要求')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auth/setup-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || '设置失败')
|
||||
} else {
|
||||
setSuccess(true)
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<h1 style={styles.title}>链接无效</h1>
|
||||
<p style={styles.text}>缺少设置密码所需的 token,请检查链接是否完整。</p>
|
||||
<a href="/login" style={styles.link}>返回登录</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<h1 style={{ ...styles.title, color: '#16a34a' }}>密码已设置</h1>
|
||||
<p style={styles.text}>您的密码已成功设置,现在可以使用新密码登录。</p>
|
||||
<a href="/login" style={styles.link}>前往登录</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<form onSubmit={handleSubmit} style={styles.card}>
|
||||
<h1 style={styles.title}>设置登录密码</h1>
|
||||
<p style={styles.text}>请为您的 OA 账号设置登录密码。</p>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<label style={styles.label}>
|
||||
新密码
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
placeholder="至少 8 位,大写/小写/数字/特殊字符 4 选 3"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{password.length > 0 && (
|
||||
<div style={styles.complexityPanel}>
|
||||
<p style={styles.complexityHint}>需满足至少 8 位 + 以下 3 项:</p>
|
||||
{checks.map(c => {
|
||||
const ok = c.key === 'minLength' ? strength.minLength : strength[c.key]
|
||||
return (
|
||||
<div key={c.key} style={styles.checkItem}>
|
||||
<span style={{
|
||||
display: 'inline-flex', width: 18, height: 18, borderRadius: '50%',
|
||||
background: ok ? '#dcfce7' : '#f1f5f9',
|
||||
color: ok ? '#16a34a' : '#94a3b8',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, flexShrink: 0,
|
||||
}}>
|
||||
{ok ? '✓' : '—'}
|
||||
</span>
|
||||
<span style={{ color: ok ? '#16a34a' : '#64748b' }}>{c.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p style={{ fontSize: 12, marginTop: 6, color: isStrong ? '#16a34a' : '#d97706' }}>
|
||||
{isStrong ? '密码强度符合要求' : `已满足 ${strength.score} 项(需至少 3 项)${strength.minLength ? '' : ',长度不足 8 位'}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label style={styles.label}>
|
||||
确认密码
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
style={{
|
||||
...styles.input,
|
||||
borderColor: passwordMismatch ? '#dc2626' : '#cbd5e1',
|
||||
}}
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
{passwordMismatch && (
|
||||
<p style={{ color: '#dc2626', fontSize: 12, marginTop: 6 }}>两次输入的密码不一致</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !isStrong || passwordMismatch}
|
||||
style={{
|
||||
...styles.btn,
|
||||
background: loading || !isStrong || passwordMismatch ? '#cbd5e1' : '#2563eb',
|
||||
cursor: loading || !isStrong || passwordMismatch ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? '设置中...' : passwordMismatch ? '请确认密码一致' : !isStrong ? '请满足密码复杂度要求' : '设置密码'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={styles.loadingFallback}>加载中...</div>}>
|
||||
<SetupPasswordForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
minHeight: '100vh', padding: '20px',
|
||||
background: '#f1f5f9',
|
||||
},
|
||||
loadingFallback: {
|
||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
minHeight: '100vh', color: '#64748b', fontSize: '14px',
|
||||
},
|
||||
card: {
|
||||
background: '#fff', borderRadius: '12px',
|
||||
padding: '40px', maxWidth: '420px', width: '100%',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
|
||||
},
|
||||
title: {
|
||||
fontSize: '22px', fontWeight: 700, margin: '0 0 12px',
|
||||
color: '#0f172a',
|
||||
},
|
||||
text: {
|
||||
fontSize: '14px', color: '#475569', margin: '0 0 24px', lineHeight: 1.6,
|
||||
},
|
||||
label: {
|
||||
display: 'block', marginBottom: '16px',
|
||||
fontSize: '13px', fontWeight: 500, color: '#334155',
|
||||
},
|
||||
input: {
|
||||
display: 'block', width: '100%', marginTop: '6px',
|
||||
padding: '10px 14px', borderRadius: '8px',
|
||||
border: '1px solid #cbd5e1', background: '#fff',
|
||||
color: '#0f172a', fontSize: '14px', boxSizing: 'border-box' as any,
|
||||
outline: 'none',
|
||||
},
|
||||
complexityPanel: {
|
||||
marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4,
|
||||
},
|
||||
complexityHint: {
|
||||
fontSize: 12, color: '#64748b', margin: '0 0 2px',
|
||||
},
|
||||
checkItem: {
|
||||
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
width: '100%', padding: '12px', background: '#2563eb',
|
||||
color: '#fff', border: 'none', borderRadius: '8px',
|
||||
fontSize: '15px', fontWeight: 600,
|
||||
marginTop: '8px',
|
||||
},
|
||||
error: {
|
||||
background: '#fef2f2', color: '#dc2626', fontSize: '13px',
|
||||
padding: '10px 14px', borderRadius: '8px', marginBottom: '16px',
|
||||
},
|
||||
link: {
|
||||
display: 'inline-block', marginTop: '16px', color: '#2563eb',
|
||||
textDecoration: 'none', fontSize: '14px', fontWeight: 500,
|
||||
},
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export default function HeaderUI({ displayName, isAdmin, backLabel }: Props) {
|
|||
position: 'sticky', top: 0, zIndex: 50, height: 56,
|
||||
background: 'var(--bg-card)', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1160, height: '100%', margin: '0 auto', padding: '0 28px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div className="header-inner" style={{ height: '100%', padding: '0 clamp(16px, 3vw, 48px)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* 左侧 */}
|
||||
{backLabel ? (
|
||||
<a href="/" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [dark, setDark] = useState(true)
|
||||
const [theme, setTheme] = useState<Theme>('auto')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains('dark'))
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
setTheme(stored || 'auto')
|
||||
applyTheme(stored || 'auto')
|
||||
}, [])
|
||||
|
||||
function toggle() {
|
||||
const next = !dark
|
||||
setDark(next)
|
||||
document.documentElement.classList.toggle('dark', next)
|
||||
localStorage.setItem('theme', next ? 'dark' : 'light')
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
if (t === 'auto') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.add(prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
root.classList.add(t)
|
||||
}
|
||||
}
|
||||
|
||||
function select(t: Theme) {
|
||||
setTheme(t)
|
||||
localStorage.setItem('theme', t)
|
||||
applyTheme(t)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const icons: Record<Theme, string> = { light: '☀', dark: '☾', auto: '◐' }
|
||||
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="theme-toggle-btn"
|
||||
title={dark ? '切换到亮色模式' : '切换到暗色模式'}
|
||||
style={{
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setOpen(!open)} title="切换主题" style={{
|
||||
width: 36, height: 36, borderRadius: 8, border: 'none',
|
||||
background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||
background: open ? 'var(--bg-hover)' : 'transparent', color: 'var(--text-secondary)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{dark ? '☀' : '☾'}
|
||||
}}>
|
||||
{icons[theme]}
|
||||
</button>
|
||||
<style>{`.theme-toggle-btn:hover { background: var(--bg-hover) !important; }`}</style>
|
||||
</>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: 4,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, padding: '4px 0', minWidth: 100, zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
|
||||
<button key={t} onClick={() => select(t)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '6px 12px', border: 'none', background: t === theme ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text)', cursor: 'pointer', fontSize: 13, textAlign: 'left',
|
||||
}}>
|
||||
<span>{icons[t]}</span>
|
||||
<span>{labels[t]}</span>
|
||||
{t === theme && <span style={{ marginLeft: 'auto', color: '#2563eb' }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,19 +1,11 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
import { Resend } from 'resend'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtphz.qiye.163.com',
|
||||
port: Number(process.env.SMTP_PORT) || 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'gxp@qx002575.com',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
})
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
export async function sendCredentialsEmail(
|
||||
export async function sendSetupLinkEmail(
|
||||
to: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setupUrl: string,
|
||||
displayName: string,
|
||||
) {
|
||||
const name = displayName || username
|
||||
|
|
@ -29,18 +21,21 @@ export async function sendCredentialsEmail(
|
|||
</td></tr>
|
||||
<tr><td style="padding:32px;">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#334155;">您好,<strong>${name}</strong>:</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#475569;line-height:1.7;">您的 OA 统一门户账号已创建,请使用以下信息登录:</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#475569;line-height:1.7;">您的 OA 统一门户账号已创建,请点击下方按钮设置登录密码:</p>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr><td align="center">
|
||||
<a href="${setupUrl}" style="display:inline-block;padding:14px 40px;background:#2563eb;color:#fff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">设置登录密码</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
|
||||
<tr><td style="padding:9px 16px;width:80px;font-size:13px;color:#64748b;">用户名</td><td style="padding:9px 16px;font-size:14px;font-weight:600;color:#0f172a;">${username}</td></tr>
|
||||
<tr><td style="padding:9px 16px;font-size:13px;color:#64748b;border-top:1px solid #e2e8f0;">密 码</td><td style="padding:9px 16px;font-size:14px;font-weight:700;font-family:monospace;color:#2563eb;border-top:1px solid #e2e8f0;letter-spacing:0.05em;">${password}</td></tr>
|
||||
</table>
|
||||
<p style="margin:20px 0 0;font-size:14px;color:#475569;">登录地址:<a href="https://oa.tlyq.ai" style="color:#2563eb;text-decoration:none;">https://oa.tlyq.ai</a></p>
|
||||
<div style="margin-top:24px;padding:14px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;">
|
||||
<p style="margin:0;font-size:13px;color:#92400e;line-height:1.6;"><strong>⚠ 请注意:</strong></p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。请登录 OA 后在个人资料页修改。</p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">请在首次登录后及时修改密码。</p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">此链接 <strong>24 小时内有效</strong>,过期后需联系管理员重新创建账号。</p>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;">修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。</p>
|
||||
</div>
|
||||
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8;">此邮件由系统自动发送,请勿回复。<br>如有疑问,请联系管理员:gxp@qx002575.com</p>
|
||||
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8;">此邮件由系统自动发送,请勿回复。<br>如有疑问,请联系管理员。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
|
@ -50,24 +45,20 @@ export async function sendCredentialsEmail(
|
|||
|
||||
const text = `您好,${name}:
|
||||
|
||||
您的 OA 统一门户账号已创建,请使用以下信息登录:
|
||||
您的 OA 统一门户账号已创建,请点击以下链接设置登录密码:
|
||||
|
||||
${setupUrl}
|
||||
|
||||
用户名:${username}
|
||||
密 码:${password}
|
||||
|
||||
登录地址:https://oa.tlyq.ai
|
||||
|
||||
⚠ 请注意:
|
||||
此链接 24 小时内有效,过期后需联系管理员重新创建账号。
|
||||
修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。
|
||||
请登录 OA 后在个人资料页修改密码。
|
||||
|
||||
请在首次登录后及时修改密码。
|
||||
此邮件由系统自动发送,请勿回复。`
|
||||
|
||||
此邮件由系统自动发送,请勿回复。
|
||||
如有疑问,请联系管理员:gxp@qx002575.com`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'gxp@qx002575.com',
|
||||
await resend.emails.send({
|
||||
from: 'OA 统一门户 <noreply@tlyq.ai>',
|
||||
to,
|
||||
subject: '您的 OA 统一门户账号已创建',
|
||||
text,
|
||||
|
|
|
|||
|
|
@ -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 || ''
|
||||
|
||||
export interface SharedSession {
|
||||
|
|
@ -10,51 +12,34 @@ export interface SharedSession {
|
|||
exp: number
|
||||
}
|
||||
|
||||
function base64url(str: string): string {
|
||||
return Buffer.from(str).toString('base64url')
|
||||
}
|
||||
|
||||
// 保持原有签名:signSharedJwt(payload, expiresIn) → 内部改用 signJwtV2
|
||||
export function signSharedJwt(
|
||||
payload: { username: string; displayName: string },
|
||||
expiresIn: number = 7 * 24 * 60 * 60
|
||||
): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
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('.')
|
||||
return signJwtV2({ secret: JWT_SECRET, payload, iss: 'oa.tlyq.ai', expiresInSeconds: expiresIn })
|
||||
}
|
||||
|
||||
// 保持原有签名:verifySharedJwt(token) — 兼容旧 token(无 iss)和新 token(有 iss)
|
||||
export function verifySharedJwt(token: string): SharedSession | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
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
|
||||
const payload = verifyJwt(token, JWT_SECRET)
|
||||
if (!payload) return null
|
||||
return {
|
||||
username: payload.username,
|
||||
displayName: payload.displayName,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
username: payload.username as string,
|
||||
displayName: (payload.displayName || payload.username) as string,
|
||||
iat: payload.iat as number,
|
||||
exp: payload.exp as number,
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// 保持原有签名:sharedCookieConfig(maxAge)
|
||||
export function sharedCookieConfig(maxAge: number = 7 * 24 * 60 * 60) {
|
||||
return {
|
||||
name: 'tlyq_session',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: COOKIE_DOMAIN,
|
||||
domain: COOKIE_DOMAIN || undefined,
|
||||
path: '/',
|
||||
maxAge,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,45 @@
|
|||
import { Client, InvalidCredentialsError } from 'ldapts'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890'
|
||||
const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai'
|
||||
|
||||
// 运行时从 LLDAP 容器动态获取 admin 密码
|
||||
// 从环境变量获取 LLDAP admin 密码(优先使用环境变量,fallback 到 docker exec)
|
||||
function getLdapAdminPassword(): string {
|
||||
if (process.env.LLDAP_ADMIN_PASSWORD) {
|
||||
return process.env.LLDAP_ADMIN_PASSWORD
|
||||
}
|
||||
try {
|
||||
const { execFileSync } = require('child_process') as typeof import('child_process')
|
||||
return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'],
|
||||
{ timeout: 3000 }).toString().trim()
|
||||
} catch { 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 组(用于管理员权限判断)
|
||||
export async function isLldapAdmin(username: string): Promise<boolean> {
|
||||
if (username === 'admin') return true // 默认 admin 永远是管理员
|
||||
const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}`
|
||||
const adminPass = getLdapAdminPassword()
|
||||
const client = new Client({ url: LDAP_URL, timeout: 5000 })
|
||||
|
||||
try {
|
||||
const adminPass = getLdapAdminPassword()
|
||||
await client.bind(adminDn, adminPass)
|
||||
const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}`
|
||||
const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, {
|
||||
|
|
@ -31,7 +51,7 @@ export async function isLldapAdmin(username: string): Promise<boolean> {
|
|||
} catch {
|
||||
return false // LLDAP 不可达 → 保守拒绝,非 admin 不放行
|
||||
} finally {
|
||||
await client.unbind()
|
||||
try { await client.unbind() } catch { /* */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Issuer } from 'openid-client'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai'
|
||||
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'oa-oidc'
|
||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''
|
||||
const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback'
|
||||
|
||||
let oidcClient: any = null
|
||||
let lastDiscovery = 0
|
||||
const DISCOVERY_TTL = 3600000 // 1 小时
|
||||
|
||||
export async function getOidcClient() {
|
||||
const now = Date.now()
|
||||
if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) {
|
||||
return oidcClient
|
||||
}
|
||||
|
||||
try {
|
||||
const issuer = await Issuer.discover(AUTHELIA_URL)
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: OIDC_CLIENT_ID,
|
||||
client_secret: OIDC_CLIENT_SECRET,
|
||||
redirect_uris: [OIDC_REDIRECT_URI],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
})
|
||||
lastDiscovery = now
|
||||
return oidcClient
|
||||
} catch (error) {
|
||||
console.error('OIDC discovery 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePKCE() {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url')
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
|
||||
return { codeVerifier, codeChallenge }
|
||||
}
|
||||
|
||||
export function generateState() {
|
||||
return crypto.randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function generateNonce() {
|
||||
return crypto.randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-same-across-all-sites'
|
||||
|
||||
function base64url(str: string): string {
|
||||
return Buffer.from(str).toString('base64url')
|
||||
}
|
||||
|
||||
export interface SetupTokenPayload {
|
||||
username: string
|
||||
purpose: 'password-setup'
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
export function signSetupToken(username: string, expiresIn: number = 24 * 60 * 60): string {
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const body: Omit<SetupTokenPayload, 'exp'> & { exp: number } = {
|
||||
username,
|
||||
purpose: 'password-setup',
|
||||
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('.')
|
||||
}
|
||||
|
||||
export function verifySetupToken(token: string): { username: string } | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
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: SetupTokenPayload = JSON.parse(
|
||||
Buffer.from(parts[1], 'base64url').toString()
|
||||
)
|
||||
if (payload.purpose !== 'password-setup') return null
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return { username: payload.username }
|
||||
} catch { return null }
|
||||
}
|
||||
|
|
@ -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))
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,62 +1,14 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验
|
||||
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
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 }
|
||||
}
|
||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
function isValidPayload(payload: Record<string, unknown> | null): boolean {
|
||||
if (!payload) return false
|
||||
return !(payload.exp && (payload.exp as number) < Math.floor(Date.now() / 1000))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 登录/退出/API 路径放行(API 路由自行验证)
|
||||
if (pathname === '/login' || 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: '/',
|
||||
export const middleware = createMiddlewareV2({
|
||||
jwtSecret,
|
||||
cookieDomain,
|
||||
allowedIssuers: ['*'], // 迁移模式
|
||||
publicPaths: ['/login', '/api/auth', '/api/health', '/api/admin', '/setup-password', '/_next', '/favicon.ico'],
|
||||
})
|
||||
return noCache(response)
|
||||
}
|
||||
|
||||
return noCache(NextResponse.redirect(new URL('/login', request.url)))
|
||||
}
|
||||
|
||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -22,9 +22,13 @@
|
|||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"./shared/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue