Compare commits

..

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

21 changed files with 330 additions and 1065 deletions

View File

@ -1,19 +1,5 @@
# 变更日志 # 变更日志
## 2026-05-18
- [安全] 邮件发送从 163 企业邮箱 SMTP → Resend APISending Access 权限),凭证从邮箱完整密码降级为仅可发信的 API Key
- [安全] 创建用户不再邮件发送明文密码改为发送一次性密码设置链接JWT token24 小时有效),密码全程只有用户自己知道
- [新增] `/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 ## 2026-05-14
- [新增] 管理员权限接入 LLDAP `lldap_admin` 组:`isLldapAdmin()` 通过 LDAP 查询组成员决定管理权限,不再硬编码 `username === 'admin'`;将用户加入该组即可获得 OA 管理权限 - [新增] 管理员权限接入 LLDAP `lldap_admin` 组:`isLldapAdmin()` 通过 LDAP 查询组成员决定管理权限,不再硬编码 `username === 'admin'`;将用户加入该组即可获得 OA 管理权限

View File

@ -33,10 +33,7 @@ npm run build # 生产构建
| `src/middleware.ts` | 全局路由守卫:检查 `tlyq_session` cookie未认证跳转 /login | | `src/middleware.ts` | 全局路由守卫:检查 `tlyq_session` cookie未认证跳转 /login |
| `src/lib/jwt.ts` | 共享 JWT 签发/验证HS256与 assets/issue 共用密钥和格式) | | `src/lib/jwt.ts` | 共享 JWT 签发/验证HS256与 assets/issue 共用密钥和格式) |
| `src/lib/ldap.ts` | LLDAP 认证ldapAuth+ 密码修改ldapChangePassword+ 用户存在性检查 | | `src/lib/ldap.ts` | LLDAP 认证ldapAuth+ 密码修改ldapChangePassword+ 用户存在性检查 |
| `src/lib/email.ts` | 邮件发送Resend API创建用户时发送密码设置链接不含明文密码 | | `src/lib/email.ts` | 邮件发送nodemailer163 企业邮箱,创建用户时发送凭证) |
| `src/lib/setup-token.ts` | 一次性密码设置 token 签发/验证JWT24 小时有效) |
| `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/page.tsx` | 门户首页:站点卡片导航(核心系统 + 其他站点) |
| `src/app/login/page.tsx` | 登录页LLDAP 认证) | | `src/app/login/page.tsx` | 登录页LLDAP 认证) |
| `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) | | `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) |
@ -74,10 +71,14 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
| `LDAP_URL` | `ldap://localhost:3890` | `ldap://lldap:3890` | | `LDAP_URL` | `ldap://localhost:3890` | `ldap://lldap:3890` |
| `LDAP_BASE_DN` | `dc=tlyq,dc=ai` | 同 | | `LDAP_BASE_DN` | `dc=tlyq,dc=ai` | 同 |
| `LDAP_ADMIN_DN` | `uid=admin,ou=people,dc=tlyq,dc=ai` | 同 | | `LDAP_ADMIN_DN` | `uid=admin,ou=people,dc=tlyq,dc=ai` | 同 |
| — | 运行时动态读取 | LLDAP admin 密码通过 `docker exec lldap printenv` 获取,不存本地 | | `LDAP_ADMIN_PASS` | `admin123` | LLDAP admin 密码 |
| `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) | | `JWT_SECRET` | `dev-secret-key-local` | 强随机值(与 assets/issue 相同) |
| `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` | | `COOKIE_DOMAIN` | `""`(空) | `.tlyq.ai` |
| `RESEND_API_KEY` | `re_xxxxxxxxxxxx` | Resend API KeySending Access 权限) | | `SMTP_HOST` | `smtphz.qiye.163.com` | 163 企业邮箱 |
| `SMTP_PORT` | `465` | SSL 端口 |
| `SMTP_USER` | `gxp@qx002575.com` | 发件邮箱 |
| `SMTP_PASS` | 见 .env | 邮箱密码 |
| `SMTP_FROM` | `gxp@qx002575.com` | 发件人地址 |
### `.env` 示例 ### `.env` 示例
@ -85,10 +86,15 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证
LDAP_URL=ldap://localhost:3890 LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai LDAP_BASE_DN=dc=tlyq,dc=ai
LDAP_ADMIN_DN=uid=admin,ou=people,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 JWT_SECRET=dev-secret-key-local
COOKIE_DOMAIN= COOKIE_DOMAIN=
NODE_ENV=development NODE_ENV=development
RESEND_API_KEY=re_xxxxxxxxxxxx SMTP_HOST=smtphz.qiye.163.com
SMTP_PORT=465
SMTP_USER=gxp@qx002575.com
SMTP_PASS=
SMTP_FROM=gxp@qx002575.com
``` ```
--- ---
@ -130,18 +136,6 @@ 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 ### OA 502 Bad Gateway

View File

@ -13,7 +13,11 @@ services:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- ASSETS_DB_PATH=/data/other-sites/assets/assets.db - ASSETS_DB_PATH=/data/other-sites/assets/assets.db
- ISSUE_DB_PATH=/data/other-sites/issue/issue.db - ISSUE_DB_PATH=/data/other-sites/issue/issue.db
- RESEND_API_KEY=${RESEND_API_KEY} - SMTP_HOST=smtphz.qiye.163.com
- SMTP_PORT=465
- SMTP_USER=gxp@qx002575.com
- SMTP_PASS=qhQcTaR6rAzCnHQk
- SMTP_FROM=gxp@qx002575.com
volumes: volumes:
- ./.next:/app/.next - ./.next:/app/.next
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock

82
package-lock.json generated
View File

@ -10,12 +10,13 @@
"dependencies": { "dependencies": {
"ldapts": "^6.0.0", "ldapts": "^6.0.0",
"next": "^15.0.0", "next": "^15.0.0",
"nodemailer": "^8.0.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"resend": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
@ -690,12 +691,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -723,6 +718,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@ -808,12 +813,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/ldapts": { "node_modules/ldapts": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-6.0.0.tgz", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-6.0.0.tgz",
@ -908,18 +907,21 @@
} }
} }
}, },
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -969,27 +971,6 @@
"react": "^19.2.6" "react": "^19.2.6"
} }
}, },
"node_modules/resend": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
"integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.4",
"svix": "1.92.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -1069,16 +1050,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/strict-event-emitter-types": { "node_modules/strict-event-emitter-types": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
@ -1108,15 +1079,6 @@
} }
} }
}, },
"node_modules/svix": {
"version": "1.92.2",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz",
"integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@ -10,12 +10,13 @@
"dependencies": { "dependencies": {
"ldapts": "^6.0.0", "ldapts": "^6.0.0",
"next": "^15.0.0", "next": "^15.0.0",
"resend": "^6.0.3", "nodemailer": "^8.0.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }

View File

@ -1,37 +0,0 @@
// 批量同步 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) })

View File

@ -6,26 +6,6 @@ import RoleManager from './role-manager'
interface Role { name: string; display_name: string } interface Role { name: string; display_name: string }
interface LdapUser { username: string; email: string; displayName: string; createdAt: 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() { export default function AdminUsersPage() {
const [tab, setTab] = useState<'create' | 'manage' | 'roles'>('create') const [tab, setTab] = useState<'create' | 'manage' | 'roles'>('create')
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@ -42,7 +22,8 @@ export default function AdminUsersPage() {
const [loginUser, setLoginUser] = useState('') const [loginUser, setLoginUser] = useState('')
const [loginDisplayName, setLoginDisplayName] = useState('') const [loginDisplayName, setLoginDisplayName] = useState('')
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [syncingEmails, setSyncingEmails] = useState(false)
// 密码弹窗
const [showPwd, setShowPwd] = useState(false) const [showPwd, setShowPwd] = useState(false)
const [generatedPwd, setGeneratedPwd] = useState('') const [generatedPwd, setGeneratedPwd] = useState('')
const [pwdUser, setPwdUser] = useState('') const [pwdUser, setPwdUser] = useState('')
@ -65,36 +46,62 @@ export default function AdminUsersPage() {
try { try {
const res = await fetch('/api/auth/me') const res = await fetch('/api/auth/me')
const d = await res.json() 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 {} } catch {}
}, []) }, [])
useEffect(() => { fetchRoles(); fetchLoginUser() }, [fetchRoles, fetchLoginUser]) useEffect(() => { fetchRoles(); fetchLoginUser() }, [fetchRoles, fetchLoginUser])
async function refreshUsers() { 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) { async function handleCreate(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setLoading(true) setResult(null); setLoading(true)
try { try {
const res = await fetch('/api/admin/create-user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, assetsRole, issueRole, email: email || undefined }) }) const res = await fetch('/api/admin/create-user', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName, assetsRole, issueRole, email: email || undefined }),
})
const d = await res.json() const d = await res.json()
if (res.ok) { if (res.ok) {
if (d.password) { setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false) } if (d.password) {
setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false)
}
setUsername(''); setDisplayName(''); setEmail('') setUsername(''); setDisplayName(''); setEmail('')
refreshUsers() refreshUsers()
} }
showResult(res.ok, d.message || d.error || '操作完成') setResult({ ok: res.ok, msg: d.message || d.error || '操作完成' })
} catch { showResult(false, '网络错误') } } catch { setResult({ ok: false, msg: '网络错误' }) }
finally { setLoading(false) } finally { setLoading(false) }
} }
async function handleCopy() { async function handleCopy() {
const text = `您好,${pwdName}\n\n您的 OA 统一门户账号已创建,请使用以下信息登录:\n\n 用户名:${pwdUser}\n 密 码:${generatedPwd}\n\n登录地址https://oa.tlyq.ai\n\n请在首次登录后及时修改密码。` const text = `您好,${pwdName}
OA 使
${pwdUser}
${generatedPwd}
https://oa.tlyq.ai
OA oa.tlyq.aiassetsissue
OA
gxp@qx002575.com`
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch {} try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch {}
} }
@ -102,169 +109,157 @@ export default function AdminUsersPage() {
if (!confirm(`确定删除用户「${target}」?\n\n将从 LLDAP 及所有站点中永久删除,不可撤销。`)) return if (!confirm(`确定删除用户「${target}」?\n\n将从 LLDAP 及所有站点中永久删除,不可撤销。`)) return
setDeleting(target) setDeleting(target)
try { 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() 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)) if (res.ok) setUsers(users.filter(u => u.username !== target))
showResult(res.ok, res.ok ? `已删除 ${target}` : (d.error || '删除失败')) } catch { setResult({ ok: false, msg: '网络错误' }) }
} catch { showResult(false, '网络错误') }
finally { setDeleting(null) } finally { setDeleting(null) }
} }
async function handleSyncEmails() { const inputStyle: React.CSSProperties = {
setSyncingEmails(true) width: '100%', height: 42, padding: '0 12px',
try { background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8,
const res = await fetch('/api/admin/sync-emails', { method: 'POST' }) fontSize: 14, outline: 'none', boxSizing: 'border-box', color: 'var(--text)',
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 ( return (
<div style={s.wrap}> <div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
<HeaderUI displayName={loginDisplayName || loginUser} isAdmin={isAdmin} backLabel="用户管理" /> <HeaderUI displayName={loginDisplayName || loginUser} isAdmin={isAdmin} backLabel="用户管理" />
{/* 密码弹窗 */}
{showPwd && ( {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={{ 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={{ 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 }}>&#128273;</div> <div style={{ fontSize: 32, marginBottom: 12 }}>&#128273;</div>
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px', color: 'var(--text)' }}></h2> <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> <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> <p style={{ fontSize: 11, color: 'var(--text-muted)', margin: '0 0 20px' }}></p>
<div style={{ display: 'flex', gap: 10 }}> <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 }}>{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, transition: 'all 0.2s' }}>{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> <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>
</div> </div>
)} )}
{/* Toast */} <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)' }}>
{result && ( {result && (
<div style={{ ...s.toast, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626', border: result.ok ? '1px solid #bbf7d0' : '1px solid #fecaca' }}> <div style={{ padding: '10px 14px', borderRadius: 8, marginBottom: 18, fontSize: 13, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626' }}>
{result.msg} {result.msg}
</div> </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' && ( {tab === 'create' && (
<form onSubmit={handleCreate}> <form onSubmit={handleCreate}>
<div style={s.grid2}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
<div style={s.field}> <div>
<label style={s.label}></label> <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={s.input} /> <input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="英文用户名" required style={inputStyle} />
</div> </div>
<div style={s.field}> <div>
<label style={s.label}></label> <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={s.input} /> <input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="可选" style={inputStyle} />
</div> </div>
</div> </div>
<div style={s.field}> <div style={{ marginBottom: 18 }}>
<label style={s.label}> <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}></span></label> <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={s.input} /> <input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com" style={inputStyle} />
</div> </div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 24, marginBottom: 20 }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: 16, marginBottom: 18 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 16 }}></div> <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 12 }}></div>
<div style={s.grid2}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div style={s.field}> <div>
<label style={s.label}></label> <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}></div>
<select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={s.select}> <select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
{assetsRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)} {assetsRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)}
</select> </select>
</div> </div>
<div style={s.field}> <div>
<label style={s.label}></label> <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 }}></div>
<select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={s.select}> <select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', appearance: 'auto' as any }}>
{issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)} {issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}{r.name}</option>)}
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<button type="submit" disabled={loading} style={{ ...s.btn, background: loading ? '#93c5fd' : '#2563eb', cursor: loading ? 'not-allowed' : 'pointer' }}> <button type="submit" disabled={loading} style={{
{loading ? '创建中...' : '创建用户'} width: '100%', height: 44, background: loading ? '#60a5fa' : '#2563eb', color: '#fff',
</button> 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>
</form> </form>
)} )}
{/* ====== 删除用户 ====== */} {/* ====== 用户管理 ====== */}
{tab === 'manage' && ( {tab === 'manage' && (
<div> <div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>LLDAP 退</p>
{users.length === 0 ? ( {users.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>...</p> <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>...</p>
) : ( ) : (
<div style={s.tableWrap}><table style={s.table}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<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 => ( {users.map(u => (
<tr key={u.username} style={{ transition: 'background 0.15s' }} <div key={u.username} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')} <div>
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}> <span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{u.username}</span>
<td style={{ ...s.td, fontWeight: 500 }}>{u.username}</td> {u.displayName !== u.username && <span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>{u.displayName}</span>}
<td style={{ ...s.td, color: (u.displayName === u.username || !u.displayName) ? 'var(--text-muted)' : 'var(--text)' }}> </div>
{(u.displayName && u.displayName !== u.username) ? u.displayName : '—'} <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
</td> <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.createdAt?.substring(0, 10)}</span>
<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') ? ( {(u.username === 'admin' || u.username === 'localadmin') ? (
<span style={{ ...s.badge, background: 'var(--bg-hover)', color: 'var(--text-muted)' }}></span> <span style={{ fontSize: 11, color: 'var(--text-muted)', padding: '2px 10px', background: 'var(--bg-hover)', borderRadius: 10 }}></span>
) : ( ) : (
<button onClick={() => handleDelete(u.username)} disabled={deleting === u.username} style={{ <button onClick={() => handleDelete(u.username)} disabled={deleting === u.username} style={{
padding: '6px 18px', borderRadius: 6, border: 'none', cursor: 'pointer', padding: '4px 14px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: deleting === u.username ? '#fecaca' : '#fef2f2', background: deleting === u.username ? '#fecaca' : '#fef2f2',
color: deleting === u.username ? '#fca5a5' : '#dc2626', color: deleting === u.username ? '#fca5a5' : '#dc2626',
fontSize: 12, fontWeight: 500, transition: 'all 0.15s', fontSize: 12, fontWeight: 500,
}}>{deleting === u.username ? '...' : '删除'}</button> }}>{deleting === u.username ? '...' : '删除'}</button>
)} )}
</td> </div>
</tr> </div>
))} ))}
</tbody> </div>
</table></div>
)} )}
</div> </div>
)} )}
{/* ====== 权限管理 ====== */} {/* ====== 角色管理 ====== */}
{tab === 'roles' && <RoleManager setResult={showResult} onUserUpdated={fetchLoginUser} />} {tab === 'roles' && (
<RoleManager
inputStyle={inputStyle}
setResult={setResult}
/>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,183 +6,161 @@ interface SiteUser { username: string; display_name: string; role: string }
interface RoleData { interface RoleData {
assetsUsers: SiteUser[]; issueUsers: SiteUser[] assetsUsers: SiteUser[]; issueUsers: SiteUser[]
assetsRoles: string[]; issueRoles: string[] assetsRoles: string[]; issueRoles: string[]
emails: Record<string, string>
} }
interface Props { setResult: (ok: boolean, msg: string) => void; onUserUpdated?: () => void } interface Props {
inputStyle: React.CSSProperties
const ss = { setResult: (r: { ok: boolean; msg: string } | null) => void
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({ setResult, onUserUpdated }: Props) { export default function RoleManager({ inputStyle, setResult }: Props) {
const [roleData, setRoleData] = useState<RoleData | null>(null) const [roleData, setRoleData] = useState<RoleData | null>(null)
const [pending, setPending] = useState<Record<string, { site: string; newRole: string }>>({}) const [pending, setPending] = useState<Record<string, { site: string; newRole: string }>>({})
const [saving, setSaving] = useState(false) 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 fetchRoleData = useCallback(async () => { const fetchRoleData = useCallback(async () => {
try { try {
const res = await fetch('/api/admin/user-roles'); const d = await res.json() const res = await fetch('/api/admin/user-roles')
if (d.users) setRoleData({ assetsUsers: d.users.assets || [], issueUsers: d.users.issue || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], emails: d.emails || {} }) const d = await res.json()
if (d.users) setRoleData({
assetsUsers: d.users.assets || [],
issueUsers: d.users.issue || [],
assetsRoles: d.assetsRoles || [],
issueRoles: d.issueRoles || [],
})
} catch {} } catch {}
}, []) }, [])
useEffect(() => { fetchRoleData() }, [fetchRoleData]) useEffect(() => { fetchRoleData() }, [fetchRoleData])
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) { function handleSelect(site: string, username: string, newRole: string, originalRole: string) {
if (newRole === originalRole) { const next = { ...pending }; delete next[`${site}:${username}`]; setPending(next) } if (newRole === originalRole) {
else { setPending({ ...pending, [`${site}:${username}`]: { site, newRole } }) } // 改回原值,清除 pending
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 { function getCurrentRole(site: string, username: string, originalRole: string): string {
return pending[`${site}:${username}`]?.newRole || originalRole const key = `${site}:${username}`
return pending[key]?.newRole || originalRole
} }
async function handleSave() { async function handleSave() {
if (Object.keys(pending).length === 0) return if (Object.keys(pending).length === 0) return
if (!confirm(`确定保存 ${Object.keys(pending).length} 项角色修改?`)) 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)) { for (const [key, { site, newRole }] of Object.entries(pending)) {
const username = key.split(':')[1] const username = key.split(':')[1]
try { 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++ if (res.ok) ok++; else fail++
} catch { fail++ } } catch { fail++ }
} }
setResult(fail === 0, `已保存:${ok} 项成功${fail > 0 ? `${fail} 项失败` : ''}`)
setPending({}); if (fail === 0) fetchRoleData(); setSaving(false) setResult({ ok: fail === 0, msg: `已保存:${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: 40 }}>...</p> if (!roleData) return <p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>...</p>
const userMap = new Map<string, { displayName: string; email: string; assetsRole: string; issueRole: string }>() // 合并两个站点用户
roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: u.role, issueRole: '—' })) const userMap = new Map<string, { displayName: string; assetsRole: string; issueRole: string }>()
roleData.issueUsers.forEach(u => { const e = userMap.get(u.username); if (e) e.issueRole = u.role; else userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: u.role }) }) roleData.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 })
})
const users = Array.from(userMap.entries()) const users = Array.from(userMap.entries())
const changed = Object.keys(pending).length const changed = Object.keys(pending).length
return ( return (
<div> <div>
<div style={ss.bar}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p style={ss.hint}>{changed > 0 && <span style={{ color: '#d97706', fontWeight: 700, marginLeft: 10 }}>{changed} </span>}</p> <p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
<button onClick={handleSave} disabled={changed === 0 || saving} style={ss.saveBtn(changed > 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',
}}
>
{saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`} {saving ? '保存中...' : `保存修改${changed > 0 ? ` (${changed})` : ''}`}
</button> </button>
</div> </div>
<div style={{ overflowX: 'auto' }}><table style={ss.table}> <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)' }}>
<thead> <div></div><div></div><div></div><div></div>
<tr> </div>
<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]) => ( {users.map(([uname, info]) => (
<tr key={uname} style={{ transition: 'background 0.15s' }} <div key={uname} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 12, alignItems: 'center', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')} <div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{uname}</div>
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}> <div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{info.displayName}</div>
<td style={{ ...ss.td, fontWeight: 500 }}>{uname}</td> {/* 资产管理 */}
<td style={ss.td}> <RoleCell
{editingDisplayName === uname ? ( site="assets" username={uname} originalRole={info.assetsRole}
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> roles={roleData.assetsRoles} pending={pending}
<input value={editDisplayNameValue} onChange={e => setEditDisplayNameValue(e.target.value)} style={ss.emailInput} /> inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
<button onClick={() => handleEditDisplayName(uname)} disabled={savingDisplayName} style={ss.miniBtn(true)}>{savingDisplayName ? '..' : '保存'}</button> />
<button onClick={() => setEditingDisplayName(null)} style={ss.miniBtn(false)}></button> {/* 工单跟踪 */}
</span> <RoleCell
) : ( site="issue" username={uname} originalRole={info.issueRole}
<span style={{ fontSize: 13, color: info.displayName === uname ? 'var(--text-muted)' : 'var(--text)' }}> roles={roleData.issueRoles} pending={pending}
{info.displayName} inputStyle={inputStyle} onSelect={handleSelect} getCurrentRole={getCurrentRole}
{(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> </div>
)}
</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} /></td>
<td style={ss.td}><RoleCell site="issue" username={uname} originalRole={info.issueRole} roles={roleData.issueRoles} pending={pending} onSelect={handleSelect} getCurrentRole={getCurrentRole} /></td>
</tr>
))} ))}
</tbody>
</table></div>
</div> </div>
) )
} }
function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole }: { function RoleCell({ site, username, originalRole, roles, pending, inputStyle, onSelect, getCurrentRole }: {
site: string; username: string; originalRole: string; roles: string[] site: string; username: string; originalRole: string; roles: string[]
pending: Record<string, { site: string; newRole: string }> pending: Record<string, { site: string; newRole: string }>
inputStyle: React.CSSProperties
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
getCurrentRole: (site: string, username: string, originalRole: string) => string getCurrentRole: (site: string, username: string, originalRole: string) => string
}) { }) {
const isReserved = username === 'admin' || username === 'localadmin' const isReserved = username === 'admin' || username === 'localadmin'
const changed = !!pending[`${site}:${username}`] const key = `${site}:${username}`
const changed = !!pending[key]
const currentRole = getCurrentRole(site, username, originalRole) const currentRole = getCurrentRole(site, username, originalRole)
if (isReserved) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{currentRole}</span>
if (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, color: 'var(--text-muted)' }}></span>
}
return ( return (
<select value={currentRole} onChange={e => onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}> <select
value={currentRole}
onChange={e => onSelect(site, username, e.target.value, originalRole)}
style={{
...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)',
}}
>
{roles.map(r => <option key={r} value={r}>{r}</option>)} {roles.map(r => <option key={r} value={r}>{r}</option>)}
</select> </select>
) )

View File

@ -4,8 +4,7 @@ import { exec } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import { isLldapAdmin } from '@/lib/ldap' import { isLldapAdmin } from '@/lib/ldap'
import { sendSetupLinkEmail } from '@/lib/email' import { sendCredentialsEmail } from '@/lib/email'
import { signSetupToken } from '@/lib/setup-token'
const execAsync = promisify(exec) const execAsync = promisify(exec)
@ -85,9 +84,8 @@ export async function POST(request: Request) {
const safeName = (displayName || username).replace(/'/g, "'\\''") const safeName = (displayName || username).replace(/'/g, "'\\''")
const safeUser = username.replace(/'/g, "'\\''") const safeUser = username.replace(/'/g, "'\\''")
const lldapEmail = email || '' const lldapEmail = `${username}@tlyq.ai`
const d = new Date() const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
const userUuid = crypto.randomUUID() const userUuid = crypto.randomUUID()
// 1. LLDAP SQLite 插入用户 // 1. LLDAP SQLite 插入用户
@ -121,13 +119,11 @@ export async function POST(request: Request) {
try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {} try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {}
} }
// 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) // 5. 如果提供了邮箱,发送凭证邮件
let emailSent = false let emailSent = false
if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
try { try {
const setupToken = signSetupToken(username) await sendCredentialsEmail(email, username, password, displayName || username)
const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}`
await sendSetupLinkEmail(email, username, setupUrl, displayName || username)
emailSent = true emailSent = true
} catch (e) { } catch (e) {
console.error('发送邮件失败:', e) console.error('发送邮件失败:', e)
@ -141,7 +137,7 @@ export async function POST(request: Request) {
roles: { assets: ar, issue: ir, applied: roleResults }, roles: { assets: ar, issue: ir, applied: roleResults },
emailSent, emailSent,
message: emailSent message: emailSent
? `用户已创建,密码设置链接已发送至 ${email}` ? `用户已创建,密码已发送至 ${email}`
: '用户已创建并同步至所有站点', : '用户已创建并同步至所有站点',
}) })
} catch (e) { } catch (e) {

View File

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

View File

@ -54,24 +54,10 @@ export async function GET() {
getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles),
]) ])
// 从 LLDAP 获取所有用户邮箱
let emails: Record<string, string> = {}
try {
const { stdout } = await execAsync(
`docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`,
{ timeout: 3000 }
)
stdout.trim().split('\n').filter(Boolean).forEach(line => {
const [uid, e] = line.split('|')
emails[uid] = e || ''
})
} catch {}
return NextResponse.json({ return NextResponse.json({
assetsRoles, assetsRoles,
issueRoles, issueRoles,
users: { assets: assetsUsers, issue: issueUsers }, users: { assets: assetsUsers, issue: issueUsers },
emails,
}) })
} catch (e) { } catch (e) {
return NextResponse.json({ error: '查询失败' }, { status: 500 }) return NextResponse.json({ error: '查询失败' }, { status: 500 })
@ -95,7 +81,7 @@ export async function PUT(request: Request) {
const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`) const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`)
if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 })
await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now') WHERE username='${username}';"`, { timeout: 3000 })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (e) { } catch (e) {

View File

@ -75,58 +75,3 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: '删除失败' }, { status: 500 }) return NextResponse.json({ error: '删除失败' }, { status: 500 })
} }
} }
// PATCH — 修改用户信息admin 权限)
export async function PATCH(request: Request) {
const isAdmin = await checkAdmin()()
if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
try {
const { username, email, displayName } = await request.json()
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
if (email === undefined && displayName === undefined) {
return NextResponse.json({ error: '至少需要 email 或 displayName' }, { status: 400 })
}
if (email !== undefined && email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 })
}
const safeUser = username.replace(/'/g, "''")
const d = new Date()
const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`
// 更新 LLDAP
let lldapSets: string[] = []
let siteSets: string[] = []
if (email !== undefined) {
const safeEmail = (email || '').replace(/'/g, "''")
lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`)
siteSets.push(`email = '${safeEmail}'`)
}
if (displayName !== undefined) {
const safeName = displayName.replace(/'/g, "''")
lldapSets.push(`display_name = '${safeName}'`)
siteSets.push(`display_name = '${safeName}'`)
}
lldapSets.push(`modified_date = '${now}'`)
siteSets.push(`updated_at = datetime('now', '+8 hours')`)
const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';`
await execAsync(
`docker exec lldap /bin/sh -c "cat > /tmp/up.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/up.sql"`,
{ timeout: 5000 }
)
// 同步更新 assets / issue
const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db'
const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db'
const siteSQL = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}';`
for (const dbPath of [assetsDb, issueDb]) {
try { await execAsync(`sqlite3 "${dbPath}" "${siteSQL}"`, { timeout: 3000 }) } catch {}
}
return NextResponse.json({ success: true, username, email, displayName })
} catch (e) {
return NextResponse.json({ error: '修改失败' }, { status: 500 })
}
}

View File

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

View File

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

View File

@ -1,94 +0,0 @@
'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>
)
}

View File

@ -3,7 +3,6 @@ import { redirect } from 'next/navigation'
import { verifySharedJwt } from '@/lib/jwt' import { verifySharedJwt } from '@/lib/jwt'
import Header from '@/components/Header' import Header from '@/components/Header'
import ChangePasswordForm from './change-password-form' import ChangePasswordForm from './change-password-form'
import EmailEditor from './email-editor'
export default async function ProfilePage() { export default async function ProfilePage() {
const cookieStore = await cookies() const cookieStore = await cookies()
@ -39,9 +38,6 @@ 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 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> </div>
<div style={{ marginTop: 18, paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<EmailEditor />
</div>
</div> </div>
<ChangePasswordForm /> <ChangePasswordForm />
</div> </div>

View File

@ -1,246 +0,0 @@
'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,
},
}

View File

@ -12,7 +12,7 @@ export default function HeaderUI({ displayName, isAdmin, backLabel }: Props) {
position: 'sticky', top: 0, zIndex: 50, height: 56, position: 'sticky', top: 0, zIndex: 50, height: 56,
background: 'var(--bg-card)', borderBottom: '1px solid var(--border)', background: 'var(--bg-card)', borderBottom: '1px solid var(--border)',
}}> }}>
<div className="header-inner" style={{ height: '100%', padding: '0 clamp(16px, 3vw, 48px)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ maxWidth: 1160, height: '100%', margin: '0 auto', padding: '0 28px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* 左侧 */} {/* 左侧 */}
{backLabel ? ( {backLabel ? (
<a href="/" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', color: 'var(--text-secondary)', fontSize: 15 }}> <a href="/" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', color: 'var(--text-secondary)', fontSize: 15 }}>

View File

@ -1,11 +1,19 @@
import { Resend } from 'resend' import nodemailer from 'nodemailer'
const resend = new Resend(process.env.RESEND_API_KEY) 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 || '',
},
})
export async function sendSetupLinkEmail( export async function sendCredentialsEmail(
to: string, to: string,
username: string, username: string,
setupUrl: string, password: string,
displayName: string, displayName: string,
) { ) {
const name = displayName || username const name = displayName || username
@ -21,21 +29,18 @@ export async function sendSetupLinkEmail(
</td></tr> </td></tr>
<tr><td style="padding:32px;"> <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 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;"> <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;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;">&nbsp;&nbsp;</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> </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;"> <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>&#9888; </strong></p> <p style="margin:0;font-size:13px;color:#92400e;line-height:1.6;"><strong>&#9888; </strong></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.aiassetsissue OA </p>
<p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;"> OA oa.tlyq.aiassetsissue </p> <p style="margin:6px 0 0;font-size:13px;color:#92400e;line-height:1.6;"></p>
</div> </div>
<p style="margin:24px 0 0;font-size:12px;color:#94a3b8;"><br></p> <p style="margin:24px 0 0;font-size:12px;color:#94a3b8;"><br>gxp@qx002575.com</p>
</td></tr> </td></tr>
</table> </table>
</td></tr> </td></tr>
@ -45,20 +50,24 @@ export async function sendSetupLinkEmail(
const text = `您好,${name} const text = `您好,${name}
OA OA 使
${setupUrl} ${username}
${password}
${username} https://oa.tlyq.ai
24
OA oa.tlyq.aiassetsissue OA oa.tlyq.aiassetsissue
OA
`
await resend.emails.send({
from: 'OA 统一门户 <noreply@tlyq.ai>', gxp@qx002575.com`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'gxp@qx002575.com',
to, to,
subject: '您的 OA 统一门户账号已创建', subject: '您的 OA 统一门户账号已创建',
text, text,

View File

@ -1,48 +0,0 @@
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 }
}

View File

@ -24,8 +24,8 @@ function noCache(response: NextResponse) {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// 登录/设置密码/API 路径放行API 路由自行验证) // 登录/退出/API 路径放行API 路由自行验证)
if (pathname === '/login' || pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) { if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
return NextResponse.next() return NextResponse.next()
} }
// /admin 管理页面需要认证 // /admin 管理页面需要认证