feat: 权限管理支持修改显示名 + PATCH API 扩展

- PATCH /api/admin/users 支持 email + displayName 任一或同时修改
- 权限管理表格:显示名列支持 inline 编辑
- 同步更新 LLDAP + assets/issue
This commit is contained in:
aiyimickey 2026-05-18 18:08:08 +08:00
parent 8dbb841489
commit 868c79891f
2 changed files with 65 additions and 12 deletions

View File

@ -30,6 +30,9 @@ export default function RoleManager({ setResult }: Props) {
const [editingEmail, setEditingEmail] = useState<string | null>(null) const [editingEmail, setEditingEmail] = useState<string | null>(null)
const [editEmailValue, setEditEmailValue] = useState('') const [editEmailValue, setEditEmailValue] = useState('')
const [savingEmail, setSavingEmail] = useState(false) 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 {
@ -54,6 +57,24 @@ export default function RoleManager({ setResult }: Props) {
finally { setSavingEmail(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} 显示名已更新`)
} 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) { const next = { ...pending }; delete next[`${site}:${username}`]; setPending(next) }
else { setPending({ ...pending, [`${site}:${username}`]: { site, newRole } }) } else { setPending({ ...pending, [`${site}:${username}`]: { site, newRole } }) }
@ -107,7 +128,22 @@ export default function RoleManager({ setResult }: Props) {
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')} onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}> onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
<td style={{ ...ss.td, fontWeight: 500 }}>{uname}</td> <td style={{ ...ss.td, fontWeight: 500 }}>{uname}</td>
<td style={{ ...ss.td, color: info.displayName === uname ? 'var(--text-muted)' : 'var(--text)' }}>{info.displayName}</td> <td style={ss.td}>
{editingDisplayName === uname ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input value={editDisplayNameValue} onChange={e => setEditDisplayNameValue(e.target.value)} style={ss.emailInput} />
<button onClick={() => handleEditDisplayName(uname)} disabled={savingDisplayName} style={ss.miniBtn(true)}>{savingDisplayName ? '..' : '保存'}</button>
<button onClick={() => setEditingDisplayName(null)} style={ss.miniBtn(false)}></button>
</span>
) : (
<span style={{ fontSize: 13, color: info.displayName === uname ? 'var(--text-muted)' : 'var(--text)' }}>
{info.displayName}
{(uname !== 'admin' && uname !== 'localadmin') && (
<button onClick={() => { setEditingDisplayName(uname); setEditDisplayNameValue(info.displayName) }} style={{ background: 'none', border: 'none', color: '#2563eb', fontSize: 11, cursor: 'pointer', padding: '0 0 0 6px' }}></button>
)}
</span>
)}
</td>
<td style={ss.td}> <td style={ss.td}>
{editingEmail === uname ? ( {editingEmail === uname ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>

View File

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