fix: 端口映射修复 + 用户密码修改功能 + OIDC切换账号 + 生产配置模板
- docker-compose.yml: 端口映射 6181:6181 → 6181:3000 - 用户管理页面新增密码修改功能(弹窗编辑) - OIDC 路由支持 ?switch=1 参数(prompt=login) - 新增 config/.env.prod 生产环境配置模板
This commit is contained in:
parent
1bfc2577c2
commit
5cc5e658ff
|
|
@ -0,0 +1,13 @@
|
||||||
|
# monitor-ai 生产环境配置(模板)
|
||||||
|
DATABASE_PATH=/app/data/monitor.db
|
||||||
|
MONITOR_MODE=local
|
||||||
|
JWT_SECRET=__JWT_SECRET__
|
||||||
|
COOKIE_DOMAIN=.tlyq.ai
|
||||||
|
NODE_ENV=production
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
LDAP_URL=ldap://lldap:3890
|
||||||
|
AUTHELIA_URL=https://sso.tlyq.ai
|
||||||
|
OIDC_CLIENT_ID=monitor-oidc
|
||||||
|
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
|
||||||
|
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||||
|
LOCALADMIN_PASSWORD=__LOCALADMIN_PASSWORD__
|
||||||
|
|
@ -5,7 +5,7 @@ services:
|
||||||
container_name: monitor-ai
|
container_name: monitor-ai
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6181:6181"
|
- "6181:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
// src/app/admin/users/page.tsx — 用户管理
|
// src/app/admin/users/page.tsx — 用户管理(含密码修改)
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Users, RotateCw } from 'lucide-react'
|
import { Users, RotateCw, X } from 'lucide-react'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number; username: string; display_name: string | null; email: string | null
|
id: number; username: string; display_name: string | null; email: string | null
|
||||||
|
|
@ -11,9 +11,10 @@ interface User {
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
const [editRole, setEditRole] = useState('')
|
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '' })
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
|
@ -30,16 +31,50 @@ export default function UsersPage() {
|
||||||
setTimeout(() => setToast(null), 3000)
|
setTimeout(() => setToast(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (id: number) => {
|
const openEdit = (user: User) => {
|
||||||
|
setEditingUser(user)
|
||||||
|
setEditForm({
|
||||||
|
display_name: user.display_name || '',
|
||||||
|
email: user.email || '',
|
||||||
|
role: user.role,
|
||||||
|
password: '',
|
||||||
|
password_confirm: '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (editForm.password && editForm.password !== editForm.password_confirm) {
|
||||||
|
setError('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editForm.password && editForm.password.length < 6) {
|
||||||
|
setError('密码至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/users/${id}`, {
|
const body: Record<string, unknown> = {
|
||||||
|
display_name: editForm.display_name,
|
||||||
|
email: editForm.email,
|
||||||
|
}
|
||||||
|
if (editForm.password) body.password = editForm.password
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/users/${editingUser!.id}`, {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ role: editRole }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() }
|
if (res.ok) {
|
||||||
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
showToast('ok', '用户已更新')
|
||||||
} catch { showToast('err', '网络错误') }
|
setEditingUser(null)
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
const d = await res.json()
|
||||||
|
setError(d.error || '保存失败')
|
||||||
|
}
|
||||||
|
} catch { setError('网络错误') }
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +89,7 @@ export default function UsersPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1">管理用户角色和权限</p>
|
<p className="text-sm text-slate-400 mt-1">管理用户角色和密码</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||||
<RotateCw size={14} /> 刷新
|
<RotateCw size={14} /> 刷新
|
||||||
|
|
@ -82,20 +117,11 @@ export default function UsersPage() {
|
||||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
|
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
|
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{editingId === user.id ? (
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
<select value={editRole} onChange={e => setEditRole(e.target.value)}
|
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
|
||||||
className="px-2 py-1 rounded border text-sm">
|
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
||||||
<option value="admin">admin</option>
|
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
||||||
<option value="editor">editor</option>
|
}`}>{user.role}</span>
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
|
|
||||||
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
|
||||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
|
||||||
}`}>{user.role}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
|
@ -103,24 +129,89 @@ export default function UsersPage() {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{editingId === user.id ? (
|
<button onClick={() => openEdit(user)}
|
||||||
<div className="flex gap-2">
|
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||||
<button onClick={() => handleSave(user.id)} disabled={saving}
|
|
||||||
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
|
||||||
{saving ? '保存中' : '保存'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setEditingId(null)} className="px-3 py-1 text-xs border rounded-lg">取消</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
|
|
||||||
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">编辑用户: {editingUser.username}</h2>
|
||||||
|
<button onClick={() => setEditingUser(null)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">显示名</label>
|
||||||
|
<input type="text" value={editForm.display_name}
|
||||||
|
onChange={e => setEditForm(p => ({ ...p, display_name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">邮箱</label>
|
||||||
|
<input type="email" value={editForm.email}
|
||||||
|
onChange={e => setEditForm(p => ({ ...p, email: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">角色</label>
|
||||||
|
<select value={editForm.role}
|
||||||
|
onChange={e => setEditForm(p => ({ ...p, role: e.target.value }))}
|
||||||
|
disabled={editingUser.username === 'admin' || editingUser.username === 'localadmin'}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm disabled:opacity-50">
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
|
{(editingUser.username === 'admin' || editingUser.username === 'localadmin') && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">系统保留用户,角色不可修改</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">修改密码(留空不修改)</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">新密码</label>
|
||||||
|
<input type="password" value={editForm.password} placeholder="留空不修改"
|
||||||
|
onChange={e => setEditForm(p => ({ ...p, password: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
{editForm.password && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">确认新密码</label>
|
||||||
|
<input type="password" value={editForm.password_confirm}
|
||||||
|
onChange={e => setEditForm(p => ({ ...p, password_confirm: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button onClick={() => setEditingUser(null)}
|
||||||
|
className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">取消</button>
|
||||||
|
<button onClick={handleSave} disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// PUT/PATCH /api/admin/users/[id] — 修改用户角色
|
// PUT /api/admin/users/[id] — 修改用户(角色、密码等)
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
import { dbQuery, dbExec } from '@/lib/db'
|
import { dbQuery, dbExec } from '@/lib/db'
|
||||||
|
|
@ -16,12 +17,41 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { role, display_name, email } = body
|
const { role, display_name, email, password } = body
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
const existing = dbQuery<{ id: number; username: string }>(
|
||||||
|
`SELECT id, username FROM users WHERE id = ${Number(id)}`
|
||||||
|
)
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`)
|
const details: Record<string, unknown> = {}
|
||||||
if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
|
|
||||||
if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
|
if (role !== undefined) {
|
||||||
|
// 禁止修改系统保留用户的角色
|
||||||
|
if (existing[0].username === 'admin' || existing[0].username === 'localadmin') {
|
||||||
|
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||||
|
}
|
||||||
|
updates.push(`role = '${role.replace(/'/g, "''")}'`)
|
||||||
|
details.role = role
|
||||||
|
}
|
||||||
|
if (display_name !== undefined) {
|
||||||
|
updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
|
||||||
|
details.display_name = display_name
|
||||||
|
}
|
||||||
|
if (email !== undefined) {
|
||||||
|
updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
|
||||||
|
details.email = email
|
||||||
|
}
|
||||||
|
if (password) {
|
||||||
|
const hash = bcrypt.hashSync(password, 10)
|
||||||
|
updates.push(`password_hash = '${hash.replace(/'/g, "''")}'`)
|
||||||
|
details.password = '***'
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
|
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +65,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||||
action: 'update_user',
|
action: 'update_user',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: Number(id),
|
entityId: Number(id),
|
||||||
details: body,
|
details,
|
||||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@ import { NextResponse } from 'next/server'
|
||||||
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
|
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
|
||||||
import { authConfig } from '@/lib/auth-config'
|
import { authConfig } from '@/lib/auth-config'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const switchUser = url.searchParams.get('switch') === '1'
|
||||||
|
|
||||||
const { codeVerifier, codeChallenge } = generatePkce()
|
const { codeVerifier, codeChallenge } = generatePkce()
|
||||||
const state = generateState()
|
const state = generateState()
|
||||||
const nonce = generateState()
|
const nonce = generateState()
|
||||||
|
|
||||||
const url = buildAuthorizeUrl(
|
const authorizeUrl = buildAuthorizeUrl(
|
||||||
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||||
{ codeChallenge, state, nonce },
|
{ codeChallenge, state, nonce, ...(switchUser && { prompt: 'login' }) },
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = NextResponse.redirect(url)
|
const response = NextResponse.redirect(authorizeUrl)
|
||||||
|
|
||||||
// 存储 PKCE 参数到 httpOnly cookie(5 分钟过期)
|
// 存储 PKCE 参数到 httpOnly cookie(5 分钟过期)
|
||||||
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
|
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue