diff --git a/config/.env.prod b/config/.env.prod new file mode 100644 index 0000000..573d609 --- /dev/null +++ b/config/.env.prod @@ -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__ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d412e08..9dd056e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: monitor-ai restart: unless-stopped ports: - - "6181:6181" + - "6181:3000" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./data:/app/data diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 45e576b..3fa06c3 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,7 +1,7 @@ 'use client' -// src/app/admin/users/page.tsx — 用户管理 +// src/app/admin/users/page.tsx — 用户管理(含密码修改) import { useState, useEffect, useCallback } from 'react' -import { Users, RotateCw } from 'lucide-react' +import { Users, RotateCw, X } from 'lucide-react' interface User { id: number; username: string; display_name: string | null; email: string | null @@ -11,9 +11,10 @@ interface User { export default function UsersPage() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) - const [editingId, setEditingId] = useState(null) - const [editRole, setEditRole] = useState('') + const [editingUser, setEditingUser] = useState(null) + const [editForm, setEditForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '' }) const [saving, setSaving] = useState(false) + const [error, setError] = useState('') const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null) const load = useCallback(async () => { @@ -30,16 +31,50 @@ export default function UsersPage() { 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) + setError('') try { - const res = await fetch(`/api/admin/users/${id}`, { + const body: Record = { + 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' }, - body: JSON.stringify({ role: editRole }), + body: JSON.stringify(body), }) - if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() } - else { const d = await res.json(); showToast('err', d.error || '保存失败') } - } catch { showToast('err', '网络错误') } + if (res.ok) { + showToast('ok', '用户已更新') + setEditingUser(null) + load() + } else { + const d = await res.json() + setError(d.error || '保存失败') + } + } catch { setError('网络错误') } setSaving(false) } @@ -54,7 +89,7 @@ export default function UsersPage() {

用户管理

-

管理用户角色和权限

+

管理用户角色和密码

- -
- ) : ( - - )} + ))} + + {/* 编辑弹窗 */} + {editingUser && ( +
+
+
+

编辑用户: {editingUser.username}

+ +
+ + {error && ( +
{error}
+ )} + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + + {(editingUser.username === 'admin' || editingUser.username === 'localadmin') && ( +

系统保留用户,角色不可修改

+ )} +
+
+

修改密码(留空不修改)

+
+
+ + 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" /> +
+ {editForm.password && ( +
+ + 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" /> +
+ )} +
+
+
+ +
+ + +
+
+
+ )} ) } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 51b975c..ff9e0ab 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,5 +1,6 @@ -// PUT/PATCH /api/admin/users/[id] — 修改用户角色 +// PUT /api/admin/users/[id] — 修改用户(角色、密码等) import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' import { verifyJwt } from '@shared/lib/auth/jwt' import { authConfig } from '@/lib/auth-config' import { dbQuery, dbExec } from '@/lib/db' @@ -16,12 +17,41 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const { id } = await params 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[] = [] - if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`) - if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`) - if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`) + const details: Record = {} + + 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) { return NextResponse.json({ error: '无可更新字段' }, { status: 400 }) } @@ -35,7 +65,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ action: 'update_user', entityType: 'user', entityId: Number(id), - details: body, + details, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1', }) diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts index b44d414..0c599fe 100644 --- a/src/app/api/auth/login/oidc/route.ts +++ b/src/app/api/auth/login/oidc/route.ts @@ -3,17 +3,20 @@ import { NextResponse } from 'next/server' import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc' 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 state = generateState() const nonce = generateState() - const url = buildAuthorizeUrl( + const authorizeUrl = buildAuthorizeUrl( { 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 分钟过期) const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }