From ab255412007d4aa9909b0c278aefb36faed9f0a1 Mon Sep 17 00:00:00 2001
From: aiyimickey <39365912+aiyimickey@users.noreply.github.com>
Date: Mon, 18 May 2026 16:57:07 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E4=BB=B6=20Resend=20API=20+?=
=?UTF-8?q?=20=E5=AF=86=E7=A0=81=E8=AE=BE=E7=BD=AE=E9=93=BE=E6=8E=A5=20+?=
=?UTF-8?q?=20=E9=82=AE=E7=AE=B1=E7=AE=A1=E7=90=86=20+=20UI=20=E9=87=8D?=
=?UTF-8?q?=E8=AE=BE=E8=AE=A1=20+=20=E6=97=B6=E5=8C=BA=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 邮件:163 SMTP → Resend API,发件人 noreply@tlyq.ai
- 安全:创建用户发密码设置链接(24h有效),不再明文发密码
- 新增:/setup-password 密码设置页 + setup-token 工具
- 新增:个人信息页 + 权限管理页 显示/修改邮箱
- 修复:创建用户存储真实邮箱(不再拼接 {username}@tlyq.ai)
- 修复:全站 toISOString / datetime('now') → UTC+8
- 设计:用户管理页全宽重设计,Header 1440px 统一
- 调整:用户列表创建时间精确到秒
---
CHANGELOG.md | 14 +
docker-compose.yml | 6 +-
package-lock.json | 82 ++++--
package.json | 3 +-
src/app/admin/create-user/page.tsx | 289 ++++++++++-----------
src/app/admin/create-user/role-manager.tsx | 188 +++++++-------
src/app/api/admin/create-user/route.ts | 16 +-
src/app/api/admin/user-roles/route.ts | 16 +-
src/app/api/admin/users/route.ts | 29 +++
src/app/api/auth/me/route.ts | 54 +++-
src/app/api/auth/setup-password/route.ts | 54 ++++
src/app/profile/email-editor.tsx | 94 +++++++
src/app/profile/page.tsx | 4 +
src/app/setup-password/page.tsx | 246 ++++++++++++++++++
src/components/HeaderUI.tsx | 2 +-
src/lib/email.ts | 49 ++--
src/lib/setup-token.ts | 48 ++++
src/middleware.ts | 4 +-
18 files changed, 875 insertions(+), 323 deletions(-)
create mode 100644 src/app/api/auth/setup-password/route.ts
create mode 100644 src/app/profile/email-editor.tsx
create mode 100644 src/app/setup-password/page.tsx
create mode 100644 src/lib/setup-token.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79c28a7..62947cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# 变更日志
+## 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 管理权限
diff --git a/docker-compose.yml b/docker-compose.yml
index 72899c5..66c2fc1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,11 +13,7 @@ services:
- 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}
volumes:
- ./.next:/app/.next
- /var/run/docker.sock:/var/run/docker.sock
diff --git a/package-lock.json b/package-lock.json
index 8a07e9f..c2fe864 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,13 +10,12 @@
"dependencies": {
"ldapts": "^6.0.0",
"next": "^15.0.0",
- "nodemailer": "^8.0.7",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "resend": "^6.0.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
- "@types/nodemailer": "^8.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0"
}
@@ -691,6 +690,12 @@
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -718,16 +723,6 @@
"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": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -813,6 +808,12 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-6.0.0.tgz",
@@ -907,21 +908,18 @@
}
}
},
- "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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"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": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -971,6 +969,27 @@
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1050,6 +1069,16 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
@@ -1079,6 +1108,15 @@
}
}
},
+ "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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
diff --git a/package.json b/package.json
index fa4f89d..c3079d6 100644
--- a/package.json
+++ b/package.json
@@ -10,13 +10,12 @@
"dependencies": {
"ldapts": "^6.0.0",
"next": "^15.0.0",
- "nodemailer": "^8.0.7",
+ "resend": "^6.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
- "@types/nodemailer": "^8.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0"
}
diff --git a/src/app/admin/create-user/page.tsx b/src/app/admin/create-user/page.tsx
index a6cb798..2c443d9 100644
--- a/src/app/admin/create-user/page.tsx
+++ b/src/app/admin/create-user/page.tsx
@@ -6,6 +6,25 @@ 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: '32px 40px', maxWidth: 1440 } as React.CSSProperties,
+ tabBar: { display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', marginBottom: 32 } as React.CSSProperties,
+ content: {} as React.CSSProperties,
+ grid2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 } 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('')
@@ -22,8 +41,6 @@ export default function AdminUsersPage() {
const [loginUser, setLoginUser] = useState('')
const [loginDisplayName, setLoginDisplayName] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
-
- // 密码弹窗
const [showPwd, setShowPwd] = useState(false)
const [generatedPwd, setGeneratedPwd] = useState('')
const [pwdUser, setPwdUser] = useState('')
@@ -46,62 +63,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, 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 +100,151 @@ 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)',
- }
- 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) => (
+
+ )
return (
-
+
- {/* 密码弹窗 */}
{showPwd && (
setShowPwd(false)}>
e.stopPropagation()}>
🔑
用户创建成功
-
- {pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}
-
+
{pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}
{generatedPwd}
初始密码,请妥善保存
-
+
)}
-
-
+ {/* Toast */}
+ {result && (
+
+ {result.msg}
+
+ )}
- {/* Tab */}
-
-
-
-
-
+
+ {/* Tab Bar */}
+
+ {tabItem('create', '创建用户')}
+ {tabItem('manage', '删除用户')}
+ {tabItem('roles', '权限管理')}
+
-
-
- {result && (
-
- {result.msg}
+
+ {/* ====== 创建用户 ====== */}
+ {tab === 'create' && (
+
+ )}
- {/* ====== 创建用户 ====== */}
- {tab === 'create' && (
-
- )}
-
- {/* ====== 用户管理 ====== */}
- {tab === 'manage' && (
-
-
LLDAP 不支持禁用用户,如需停用请删除。已登录用户在删除后下次操作自动退出。
- {users.length === 0 ? (
-
加载中...
- ) : (
-
+ {/* ====== 删除用户 ====== */}
+ {tab === 'manage' && (
+
+ {users.length === 0 ? (
+
加载中...
+ ) : (
+
+
+
+ | 用户名 |
+ 显示名 |
+ 邮箱 |
+ 创建时间 |
+ 操作 |
+
+
+
{users.map(u => (
-
-
- {u.username}
- {u.displayName !== u.username && {u.displayName}}
-
-
- {u.createdAt?.substring(0, 10)}
+
(e.currentTarget.style.background = 'var(--bg-hover)')}
+ onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
+ | {u.username} |
+
+ {(u.displayName && u.displayName !== u.username) ? u.displayName : '—'}
+ |
+
+ {u.email || '未设置'}
+ |
+ {u.createdAt?.substring(0, 19)} |
+
{(u.username === 'admin' || u.username === 'localadmin') ? (
- 系统保留
+ 系统保留
) : (
)}
-
-
+ |
+
))}
-
- )}
-
- )}
+
+
+ )}
+
+ )}
- {/* ====== 角色管理 ====== */}
- {tab === 'roles' && (
-
- )}
-
-
+ {/* ====== 权限管理 ====== */}
+ {tab === 'roles' &&
}
diff --git a/src/app/admin/create-user/role-manager.tsx b/src/app/admin/create-user/role-manager.tsx
index 1dd2e18..8d15763 100644
--- a/src/app/admin/create-user/role-manager.tsx
+++ b/src/app/admin/create-user/role-manager.tsx
@@ -6,161 +6,147 @@ interface SiteUser { username: string; display_name: string; role: string }
interface RoleData {
assetsUsers: SiteUser[]; issueUsers: SiteUser[]
assetsRoles: string[]; issueRoles: string[]
+ emails: Record
}
-interface Props {
- inputStyle: React.CSSProperties
- setResult: (r: { ok: boolean; msg: string } | null) => void
+interface Props { setResult: (ok: boolean, msg: string) => 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 }: Props) {
const [roleData, setRoleData] = useState(null)
const [pending, setPending] = useState>({})
const [saving, setSaving] = useState(false)
+ const [editingEmail, setEditingEmail] = useState(null)
+ const [editEmailValue, setEditEmailValue] = useState('')
+ const [savingEmail, setSavingEmail] = useState(false)
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 || [], assetsRoles: d.assetsRoles || [], issueRoles: d.issueRoles || [], emails: d.emails || {} })
} catch {}
}, [])
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) }
+ }
+
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 } })
- }
+ 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 加载中...
+ if (!roleData) return 加载中...
- // 合并两个站点用户
- const userMap = new Map()
- 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 userMap = new Map()
+ roleData.assetsUsers.forEach(u => userMap.set(u.username, { displayName: u.display_name, email: roleData.emails[u.username] || '', 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, email: roleData.emails[u.username] || '', assetsRole: '—', issueRole: u.role }) })
const users = Array.from(userMap.entries())
const changed = Object.keys(pending).length
return (
-
-
- 修改角色后点击「保存修改」统一提交
- {changed > 0 && ({changed} 项待保存)}
-
-
)
}
-function RoleCell({ site, username, originalRole, roles, pending, inputStyle, onSelect, getCurrentRole }: {
+function RoleCell({ site, username, originalRole, roles, pending, onSelect, getCurrentRole }: {
site: string; username: string; originalRole: string; roles: string[]
pending: Record
- inputStyle: React.CSSProperties
onSelect: (site: string, username: string, newRole: string, originalRole: string) => void
getCurrentRole: (site: string, username: string, originalRole: string) => string
}) {
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 {currentRole}
- }
- if (originalRole === '—' && !changed) {
- return 未同步
- }
+ if (isReserved) return {currentRole}
+ if (originalRole === '—' && !changed) return 未同步
return (
-
+
+
+
diff --git a/src/app/setup-password/page.tsx b/src/app/setup-password/page.tsx
new file mode 100644
index 0000000..d1e70e9
--- /dev/null
+++ b/src/app/setup-password/page.tsx
@@ -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 (
+
+
+
链接无效
+
缺少设置密码所需的 token,请检查链接是否完整。
+
返回登录
+
+
+ )
+ }
+
+ if (success) {
+ return (
+
+
+
密码已设置
+
您的密码已成功设置,现在可以使用新密码登录。
+
前往登录
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default function SetupPasswordPage() {
+ return (
+
加载中...}>
+
+
+ )
+}
+
+const styles: Record
= {
+ 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,
+ },
+}
diff --git a/src/components/HeaderUI.tsx b/src/components/HeaderUI.tsx
index 4cc5ab3..db74203 100644
--- a/src/components/HeaderUI.tsx
+++ b/src/components/HeaderUI.tsx
@@ -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)',
}}>
-
+
{/* 左侧 */}
{backLabel ? (
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 6f7a881..a71cf4d 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -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(
|
您好,${name}:
- 您的 OA 统一门户账号已创建,请使用以下信息登录:
+ 您的 OA 统一门户账号已创建,请点击下方按钮设置登录密码:
+
| 用户名 | ${username} |
- | 密 码 | ${password} |
- 登录地址:https://oa.tlyq.ai
⚠ 请注意:
- 修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。请登录 OA 后在个人资料页修改。
- 请在首次登录后及时修改密码。
+ 此链接 24 小时内有效,过期后需联系管理员重新创建账号。
+ 修改密码只能通过 OA 统一门户(oa.tlyq.ai),无法在子站点(assets、issue 等)中修改密码。
- 此邮件由系统自动发送,请勿回复。 如有疑问,请联系管理员:gxp@qx002575.com
+ 此邮件由系统自动发送,请勿回复。 如有疑问,请联系管理员。
|
@@ -50,24 +45,20 @@ export async function sendCredentialsEmail(
const text = `您好,${name}:
-您的 OA 统一门户账号已创建,请使用以下信息登录:
+您的 OA 统一门户账号已创建,请点击以下链接设置登录密码:
- 用户名:${username}
- 密 码:${password}
+${setupUrl}
-登录地址:https://oa.tlyq.ai
+用户名:${username}
⚠ 请注意:
+此链接 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 统一门户 ',
to,
subject: '您的 OA 统一门户账号已创建',
text,
diff --git a/src/lib/setup-token.ts b/src/lib/setup-token.ts
new file mode 100644
index 0000000..1201b68
--- /dev/null
+++ b/src/lib/setup-token.ts
@@ -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 & { 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 }
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 0fe9d03..fcd7c53 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -24,8 +24,8 @@ function noCache(response: NextResponse) {
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
- // 登录/退出/API 路径放行(API 路由自行验证)
- if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
+ // 登录/设置密码/API 路径放行(API 路由自行验证)
+ if (pathname === '/login' || pathname === '/setup-password' || pathname.startsWith('/api/auth/') || pathname.startsWith('/api/admin/')) {
return NextResponse.next()
}
// /admin 管理页面需要认证