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' && ( +
+
+
+ + setUsername(e.target.value)} placeholder="英文用户名" required style={s.input} /> +
+
+ + setDisplayName(e.target.value)} placeholder="可选,留空同用户名" style={s.input} /> +
- )} +
+ + setEmail(e.target.value)} placeholder="user@example.com" style={s.input} /> +
+
+
各站点角色
+
+
+ + +
+
+ + +
+
+
+ +
+ )} - {/* ====== 创建用户 ====== */} - {tab === 'create' && ( -
-
-
-
用户名
- setUsername(e.target.value)} placeholder="英文用户名" required style={inputStyle} /> -
-
-
显示名
- setDisplayName(e.target.value)} placeholder="可选" style={inputStyle} /> -
-
-
-
邮箱地址 (选填,填写后将密码发送至此邮箱)
- setEmail(e.target.value)} placeholder="user@example.com" style={inputStyle} /> -
-
-
各站点角色
-
-
-
资产管理
- -
-
-
工单跟踪
- -
-
-
- -

密码自动生成,创建成功后弹窗显示

-
- )} - - {/* ====== 用户管理 ====== */} - {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} 项待保存)} -

-
-
-
用户名
显示名
资产管理
工单跟踪
-
- - {users.map(([uname, info]) => ( -
-
{uname}
-
{info.displayName}
- {/* 资产管理 */} - - {/* 工单跟踪 */} - -
- ))} + + + + + + + + {users.map(([uname, info]) => ( + (e.currentTarget.style.background = 'var(--bg-hover)')} + onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}> + + + + + + + ))} + +
用户名显示名邮箱资产管理工单跟踪
{uname}{info.displayName} + {editingEmail === uname ? ( + + setEditEmailValue(e.target.value)} placeholder="user@example.com" style={ss.emailInput} /> + + + + ) : ( + + {info.email || '未设置'} + {(uname !== 'admin' && uname !== 'localadmin') && ( + + )} + + )} +
) } -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 ( - onSelect(site, username, e.target.value, originalRole)} style={ss.roleSelect(changed)}> {roles.map(r => )} ) diff --git a/src/app/api/admin/create-user/route.ts b/src/app/api/admin/create-user/route.ts index adeee67..76ad81f 100644 --- a/src/app/api/admin/create-user/route.ts +++ b/src/app/api/admin/create-user/route.ts @@ -4,7 +4,8 @@ import { exec } from 'child_process' import { promisify } from 'util' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' -import { sendCredentialsEmail } from '@/lib/email' +import { sendSetupLinkEmail } from '@/lib/email' +import { signSetupToken } from '@/lib/setup-token' const execAsync = promisify(exec) @@ -84,8 +85,9 @@ export async function POST(request: Request) { const safeName = (displayName || username).replace(/'/g, "'\\''") const safeUser = username.replace(/'/g, "'\\''") - const lldapEmail = `${username}@tlyq.ai` - const now = new Date().toISOString().replace('T', ' ').substring(0, 19) + const lldapEmail = email || '' + const d = new Date() + const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` const userUuid = crypto.randomUUID() // 1. LLDAP SQLite 插入用户 @@ -119,11 +121,13 @@ export async function POST(request: Request) { try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {} } - // 5. 如果提供了邮箱,发送凭证邮件 + // 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) let emailSent = false if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { try { - await sendCredentialsEmail(email, username, password, displayName || username) + const setupToken = signSetupToken(username) + const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}` + await sendSetupLinkEmail(email, username, setupUrl, displayName || username) emailSent = true } catch (e) { console.error('发送邮件失败:', e) @@ -137,7 +141,7 @@ export async function POST(request: Request) { roles: { assets: ar, issue: ir, applied: roleResults }, emailSent, message: emailSent - ? `用户已创建,密码已发送至 ${email}` + ? `用户已创建,密码设置链接已发送至 ${email}` : '用户已创建并同步至所有站点', }) } catch (e) { diff --git a/src/app/api/admin/user-roles/route.ts b/src/app/api/admin/user-roles/route.ts index 4b4673f..3b14859 100644 --- a/src/app/api/admin/user-roles/route.ts +++ b/src/app/api/admin/user-roles/route.ts @@ -54,10 +54,24 @@ export async function GET() { getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), ]) + // 从 LLDAP 获取所有用户邮箱 + let emails: Record = {} + try { + const { stdout } = await execAsync( + `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`, + { timeout: 3000 } + ) + stdout.trim().split('\n').filter(Boolean).forEach(line => { + const [uid, e] = line.split('|') + emails[uid] = e || '' + }) + } catch {} + return NextResponse.json({ assetsRoles, issueRoles, users: { assets: assetsUsers, issue: issueUsers }, + emails, }) } catch (e) { return NextResponse.json({ error: '查询失败' }, { status: 500 }) @@ -81,7 +95,7 @@ export async function PUT(request: Request) { const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`) if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) - await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now') WHERE username='${username}';"`, { timeout: 3000 }) + await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) return NextResponse.json({ success: true }) } catch (e) { diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 5f90b7e..14a2f97 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -75,3 +75,32 @@ export async function DELETE(request: Request) { 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 } = await request.json() + if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) + if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) + } + + const safeUser = 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/uea.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/uea.sql"`, + { timeout: 5000 } + ) + + return NextResponse.json({ success: true, username, email: email || '' }) + } catch (e) { + return NextResponse.json({ error: '修改失败' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 5937f7b..a7943df 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,8 +1,23 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' +import { exec } from 'child_process' +import { promisify } from 'util' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' +const execAsync = promisify(exec) + +async function getLldapEmail(username: string): Promise { + try { + const safeUser = username.replace(/'/g, "''") + const { stdout } = await execAsync( + `docker exec lldap /bin/sh -c "echo 'SELECT email FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`, + { timeout: 3000 } + ) + return stdout.trim() || '' + } catch { return '' } +} + export async function GET() { try { const cookieStore = await cookies() @@ -12,12 +27,47 @@ export async function GET() { const payload = verifySharedJwt(token) if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) - const admin = await isLldapAdmin(payload.username) + const [admin, email] = await Promise.all([ + isLldapAdmin(payload.username), + getLldapEmail(payload.username), + ]) return NextResponse.json({ - user: { username: payload.username, displayName: payload.displayName, isAdmin: admin }, + user: { username: payload.username, displayName: payload.displayName, email, isAdmin: admin }, }) } catch { return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) } } + +export async function PUT(request: Request) { + try { + const cookieStore = await cookies() + const token = cookieStore.get('tlyq_session')?.value + if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const payload = verifySharedJwt(token) + if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) + + const { email } = await request.json() + if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) + } + + const safeUser = payload.username.replace(/'/g, "''") + const safeEmail = (email || '').replace(/'/g, "''") + const d = new Date() + const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` + + const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';` + await execAsync( + `docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`, + { timeout: 5000 } + ) + + return NextResponse.json({ success: true, email: email || '' }) + } catch (e) { + const msg = e instanceof Error ? e.message : '修改失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/auth/setup-password/route.ts b/src/app/api/auth/setup-password/route.ts new file mode 100644 index 0000000..312dfe2 --- /dev/null +++ b/src/app/api/auth/setup-password/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server' +import { exec } from 'child_process' +import { promisify } from 'util' +import { verifySetupToken } from '@/lib/setup-token' + +const execAsync = promisify(exec) + +export async function POST(request: Request) { + try { + const { token, password } = await request.json() + if (!token || !password) { + return NextResponse.json({ error: '参数不完整' }, { status: 400 }) + } + + const payload = verifySetupToken(token) + if (!payload) { + return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 }) + } + + if (password.length < 8) { + return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 }) + } + const hasUpper = /[A-Z]/.test(password) + const hasLower = /[a-z]/.test(password) + const hasDigit = /[0-9]/.test(password) + const hasSpecial = /[^A-Za-z0-9]/.test(password) + const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length + if (score < 3) { + return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) + } + + const { stdout: adminPassOut } = await execAsync( + 'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 } + ) + const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") + + const safeUser = payload.username.replace(/'/g, "'\\''") + const safePass = password.replace(/'/g, "'\\''") + const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` + + const { stderr } = await execAsync(cmd, { timeout: 10000 }) + if (stderr && !stderr.includes('Successfully')) { + return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : '设置失败' + if (msg.includes('command not found') || msg.includes('No such container')) { + return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) + } + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/profile/email-editor.tsx b/src/app/profile/email-editor.tsx new file mode 100644 index 0000000..b8ebdbc --- /dev/null +++ b/src/app/profile/email-editor.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState, useEffect } from 'react' + +export default function EmailEditor() { + const [email, setEmail] = useState('') + const [editing, setEditing] = useState(false) + const [value, setValue] = useState('') + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + fetch('/api/auth/me') + .then(r => r.json()) + .then(d => { if (d.user) setEmail(d.user.email || '') }) + .catch(() => {}) + }, []) + + async function handleSave() { + setMessage(''); setError('') + if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + setError('邮箱格式不合法') + return + } + setSaving(true) + try { + const res = await fetch('/api/auth/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: value }), + }) + const d = await res.json() + if (res.ok) { + setEmail(d.email) + setMessage('邮箱已更新') + setEditing(false) + } else { + setError(d.error || '修改失败') + } + } catch { + setError('网络错误') + } finally { + setSaving(false) + } + } + + return ( +
+
邮箱
+ + {!editing ? ( +
+ + {email || '未设置'} + + +
+ ) : ( +
+
+ 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, + }} + /> + + +
+ {message &&

{message}

} + {error &&

{error}

} +
+ )} +
+ ) +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 0573fe7..93e4646 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import { verifySharedJwt } from '@/lib/jwt' import Header from '@/components/Header' import ChangePasswordForm from './change-password-form' +import EmailEditor from './email-editor' export default async function ProfilePage() { const cookieStore = await cookies() @@ -38,6 +39,9 @@ export default async function ProfilePage() {
{(() => { 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')}` })()}
+
+ +
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 ( +
+
+

设置登录密码

+

请为您的 OA 账号设置登录密码。

+ + {error &&
{error}
} + + + + + + +
+
+ ) +} + +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 管理页面需要认证