feat: 权限管理支持修改显示名 + PATCH API 扩展
- PATCH /api/admin/users 支持 email + displayName 任一或同时修改 - 权限管理表格:显示名列支持 inline 编辑 - 同步更新 LLDAP + assets/issue
This commit is contained in:
parent
8dbb841489
commit
868c79891f
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue