feat: 用户管理 UI 全面统一 + LDAP 邮箱同步 + 角色/Key 页面对齐

- 新增用户详情页,支持编辑(状态切换/密码确认)、键盘导航
- Modal 增加 ESC/←→/Enter 键盘支持
- LDAP 邮箱三路径同步:密码登录、SSO 免登录、批量同步接口
- 角色权限页:删除改 Modal、按钮样式对齐
- API Key 页:副标题、状态切换、创建弹窗错误提示、成功提示颜色对齐
- 全站字体/颜色一致:text-sm、dark mode 补全
- TopBar 文字颜色对齐
This commit is contained in:
gitadmin 2026-05-18 16:56:40 +08:00
parent 4a97326955
commit ba26ac97f5
12 changed files with 365 additions and 108 deletions

View File

@ -2,7 +2,15 @@
## 2026-05-18
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`(此前仅密码登录路径更新,导致 SSO 用户始终显示"从未登录"
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
- [优化] 用户管理 UI 统一Modal 支持键盘导航ESC/←→/Enter、TopBar/Button 样式与 issue-ai 对齐
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
- [优化] 角色权限页面 UI 统一:删除改用 Modal 确认、按钮样式对齐
- [优化] API Key 页面 UI 统一:增加副标题、状态可点击切换、创建弹窗增加错误提示和默认权限说明、成功提示颜色对齐
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
- [修复] 用户管理"最后登录"时间不动态更新SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
## 2026-05-15

View File

@ -21,6 +21,7 @@ export default function ApiKeysPage() {
const [newKey, setNewKey] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ApiKeyItem | null>(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState('')
async function fetchKeys() {
const res = await fetch('/api/api-keys')
@ -31,20 +32,28 @@ export default function ApiKeysPage() {
async function handleCreate() {
if (!name.trim()) return
setSaving(true)
setSaving(true); setError('')
try {
const res = await fetch('/api/api-keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
})
if (res.ok) {
const data = await res.json()
setNewKey(data.key)
setCreateOpen(false)
setName('')
fetchKeys()
}
} finally { setSaving(false) }
const data = await res.json()
if (!res.ok) { setError(data.error || '创建失败'); return }
setNewKey(data.key)
setCreateOpen(false)
setName('')
fetchKeys()
} catch { setError('创建失败') }
finally { setSaving(false) }
}
async function toggleActive(k: ApiKeyItem) {
await fetch(`/api/api-keys/${k.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: k.is_active ? 0 : 1 }),
})
fetchKeys()
}
async function handleDelete() {
@ -67,19 +76,22 @@ export default function ApiKeysPage() {
const perms: string[] = JSON.parse(r.permissions)
return <div className="flex flex-wrap gap-1">{perms.map(p => <Badge key={p} color="gray">{p}</Badge>)}</div>
}},
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
{ key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
{ key: 'last_used_at', title: '最后使用', render: (r) => r.last_used_at || '从未使用' },
{ key: 'expires_at', title: '过期时间', render: (r) => r.expires_at || '永不过期' },
{ key: 'created_at', title: '创建时间' },
{ key: 'actions', title: '操作', render: (r) => (
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)},
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key </h1>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key </h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"> API</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus size={16} /> Key</Button>
</div>
@ -101,15 +113,20 @@ export default function ApiKeysPage() {
<Button onClick={handleCreate} loading={saving}></Button>
</>
}>
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
<div className="space-y-4">
<Input label="名称" value={name} onChange={e => setName(e.target.value)} placeholder="例如:监控系统" />
<p className="text-xs text-slate-500 dark:text-slate-400"><Badge color="gray">assets:read</Badge></p>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-sm text-slate-600 dark:text-slate-400"> API Key{deleteTarget?.name}使 Key 访</p>
<div className="flex justify-end gap-3 mt-4">
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</div>
</>
}>
<p className="text-sm text-slate-600 dark:text-slate-400"> API Key{deleteTarget?.name}使 Key 访</p>
</Modal>
</div>
)

View File

@ -56,6 +56,7 @@ export default function RolesPage() {
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null)
const fetchRoles = async () => {
try {
@ -119,11 +120,10 @@ export default function RolesPage() {
finally { setSaving(false) }
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除此角色?')) return
const res = await fetch(`/api/roles/${id}`, { method: 'DELETE' })
if (res.ok) fetchRoles()
else { const d = await res.json(); alert(d.error || '删除失败') }
const handleDelete = async () => {
if (!deleteTarget) return
const res = await fetch(`/api/roles/${deleteTarget.id}`, { method: 'DELETE' })
if (res.ok) { setDeleteTarget(null); fetchRoles() }
}
const columns: Column<Role>[] = [
@ -145,7 +145,7 @@ export default function RolesPage() {
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
{!BUILTIN_ROLES.includes(r.name) && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)}
</div>
)},
@ -206,6 +206,15 @@ export default function RolesPage() {
</div>
</div>
</Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</>
}>
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
</Modal>
</div>
)
}

View File

@ -0,0 +1,126 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Badge from '@/components/ui/Badge'
import { ArrowLeft, Edit } from 'lucide-react'
interface UserDetail {
id: number; username: string; display_name: string; email: string | null
role: string; is_active: number; created_at: string; updated_at: string
last_login_at: string | null; is_online: number
}
export default function UserDetailPage() {
const { id } = useParams()
const router = useRouter()
const [user, setUser] = useState<UserDetail | null>(null)
const [loading, setLoading] = useState(true)
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
const [editOpen, setEditOpen] = useState(false)
const [form, setForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '', is_active: 1 })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function fetchUser() {
const res = await fetch(`/api/users/${id}`)
if (res.ok) {
const d = await res.json()
setUser(d.user)
setForm({ display_name: d.user.display_name, email: d.user.email || '', role: d.user.role, password: '', password_confirm: '', is_active: d.user.is_active })
}
setLoading(false)
}
async function fetchRoles() {
try {
const res = await fetch('/api/roles')
const d = await res.json()
if (d.roles) setRoleOptions(d.roles.map((r: { name: string; display_name: string }) => ({ value: r.name, label: r.display_name })))
} catch { /* ignore */ }
}
useEffect(() => { Promise.all([fetchUser(), fetchRoles()]) }, [id])
function openEdit() { setError(''); setEditOpen(true) }
async function handleSave() {
setSaving(true); setError('')
try {
if (form.password && form.password !== form.password_confirm) { setError('两次输入的密码不一致'); return }
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, is_active: form.is_active }
if (form.password) body.password = form.password
if (user!.username !== 'admin' && user!.username !== 'localadmin') body.role = form.role
const res = await fetch(`/api/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { const d = await res.json(); setError(d.error || '更新失败'); return }
setEditOpen(false)
fetchUser()
} finally { setSaving(false) }
}
if (loading) return <div className="py-20 text-center text-slate-500">...</div>
if (!user) return <div className="py-20 text-center text-slate-500"></div>
const roleLabel = roleOptions.find(r => r.value === user.role)?.label || user.role
return (
<div className="space-y-6">
<button onClick={() => router.back()} className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors">
<ArrowLeft size={16} />
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"> {user.display_name}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={openEdit}><Edit size={16} /></Button>
</div>
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
{[
{ label: '用户名', value: user.username },
{ label: '显示名称', value: user.display_name },
{ label: '邮箱', value: user.email || '-' },
{ label: '角色', value: <Badge color={user.role === 'admin' ? 'blue' : user.role === 'editor' ? 'green' : 'gray'}>{roleLabel}</Badge> },
{ label: '状态', value: <Badge color={user.is_active ? 'green' : 'red'}>{user.is_active ? '启用' : '禁用'}</Badge> },
{ label: '在线', value: <span className="inline-flex items-center gap-1.5"><span className={`inline-block w-2 h-2 rounded-full ${user.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} /><span className="text-xs text-slate-500">{user.is_online ? '在线' : '离线'}</span></span> },
{ label: '最后登录', value: user.last_login_at ? <span className="text-sm text-slate-500">{user.last_login_at}</span> : <span className="text-sm text-slate-400"></span> },
{ label: '创建时间', value: <span className="text-sm text-slate-500">{user.created_at}</span> },
].map(row => (
<div key={row.label} className="flex items-center px-6 py-4">
<span className="w-24 text-sm font-medium text-slate-600 dark:text-slate-400 shrink-0">{row.label}</span>
<span className="text-sm text-slate-900 dark:text-slate-100">{row.value}</span>
</div>
))}
</div>
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="编辑用户" footer={
<>
<Button variant="ghost" onClick={() => setEditOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}></Button>
</>
}>
{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">
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
<Select label="状态" value={String(form.is_active)} onChange={e => setForm(p => ({ ...p, is_active: parseInt(e.target.value) }))} options={[{ value: '1', label: '启用' }, { value: '0', label: '禁用' }]} />
<Input label="新密码(留空不修改)" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
{form.password && <Input label="确认新密码" type="password" value={form.password_confirm} onChange={e => setForm(p => ({ ...p, password_confirm: e.target.value }))} />}
{user.username === 'admin' || user.username === 'localadmin' ? (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<p className="text-sm text-slate-500 dark:text-slate-400 py-2"></p>
</div>
) : (
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
)}
</div>
</Modal>
</div>
)
}

View File

@ -6,7 +6,8 @@ import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Modal from '@/components/ui/Modal'
import Badge from '@/components/ui/Badge'
import { Plus, Edit, Trash2 } from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
import Link from 'next/link'
interface UserItem {
id: number; username: string; display_name: string; email: string | null;
@ -18,11 +19,10 @@ export default function UsersPage() {
const [users, setUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(true)
const [roleOptions, setRoleOptions] = useState<{ value: string; label: string }[]>([])
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<UserItem | null>(null)
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
const [createError, setCreateError] = useState('')
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null)
async function fetchUsers() {
@ -44,36 +44,25 @@ export default function UsersPage() {
Promise.all([fetchUsers(), fetchRoles()]).finally(() => setLoading(false))
}, [])
function openCreate() {
setEditing(null)
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
setError('')
setModalOpen(true)
}
function openEdit(user: UserItem) {
setEditing(user)
setForm({ username: user.username, password: '', display_name: user.display_name, email: user.email || '', role: user.role })
setError('')
setModalOpen(true)
}
async function handleSave() {
setSaving(true); setError('')
async function handleCreate() {
setCreating(true); setCreateError('')
try {
if (editing) {
const body: Record<string, unknown> = { display_name: form.display_name, email: form.email, role: form.role }
if (form.password) body.password = form.password
const res = await fetch(`/api/users/${editing.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { const d = await res.json(); setError(d.error); return }
} else {
if (!form.username || !form.password) { setError('用户名和密码不能为空'); return }
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
if (!res.ok) { const d = await res.json(); setError(d.error); return }
}
setModalOpen(false)
if (!createForm.username || !createForm.password) { setCreateError('用户名和密码不能为空'); return }
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm)
})
if (!res.ok) { const d = await res.json(); setCreateError(d.error); return }
setCreateOpen(false)
setCreateForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
fetchUsers()
} finally { setSaving(false) }
} finally { setCreating(false) }
}
async function toggleActive(user: UserItem) {
await fetch(`/api/users/${user.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: user.is_active ? 0 : 1 }) })
fetchUsers()
}
async function handleDelete() {
@ -83,27 +72,27 @@ export default function UsersPage() {
}
const columns: Column<UserItem>[] = [
{ key: 'username', title: '用户名' },
{ key: 'username', title: '用户名', render: (r) => (
<Link href={`/settings/users/${r.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{r.username}</Link>
)},
{ key: 'display_name', title: '显示名称', render: (r) => <span className="text-slate-900 dark:text-white font-medium">{r.display_name}</span> },
{ key: 'email', title: '邮箱', render: (r) => r.email || '-' },
{ key: 'email', title: '邮箱', render: (r) => <span className="text-slate-500 dark:text-slate-400">{r.email || '-'}</span> },
{ key: 'role', title: '角色', render: (r) => {
const option = roleOptions.find(ro => ro.value === r.role)
return <Badge color={r.role === 'admin' ? 'blue' : r.role === 'editor' ? 'green' : 'gray'}>{option?.label || r.role}</Badge>
} },
{ key: 'is_active', title: '状态', render: (r) => <Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge> },
}},
{ key: 'is_active', title: '状态', render: (r) => <button onClick={() => toggleActive(r)}><Badge color={r.is_active ? 'green' : 'red'}>{r.is_active ? '启用' : '禁用'}</Badge></button> },
{ key: 'is_online', title: '在线', render: (r) => (
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${r.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
<span className="text-xs text-slate-500">{r.is_online ? '在线' : '离线'}</span>
<span className="text-slate-500 dark:text-slate-400">{r.is_online ? '在线' : '离线'}</span>
</span>
)},
{ key: 'last_login_at', title: '最后登录', render: (r) => r.last_login_at ? <span className="text-xs text-slate-500">{r.last_login_at}</span> : <span className="text-xs text-slate-400"></span> },
{ key: 'created_at', title: '创建时间' },
{ key: 'last_login_at', title: '最后登录', render: (r) => r.last_login_at ? <span className="text-slate-500 dark:text-slate-400">{r.last_login_at}</span> : <span className="text-slate-400 dark:text-slate-500"></span> },
{ key: 'actions', title: '操作', render: (r) => (
<div className="flex items-center gap-1">
<button onClick={() => openEdit(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Edit size={16} /></button>
{r.username !== 'admin' && r.username !== 'localadmin' && (
<button onClick={() => setDeleteTarget(r)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"><Trash2 size={16} /></button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}><Trash2 size={14} className="text-red-500" /></Button>
)}
</div>
)},
@ -112,41 +101,38 @@ export default function UsersPage() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<Button size="sm" onClick={openCreate}><Plus size={16} /></Button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white"></h1>
<p className="text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} /></Button>
</div>
{loading ? <div className="py-20 text-center text-slate-500">...</div> : <Table columns={columns} data={users} rowKey={r => r.id} />}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editing ? '编辑用户' : '新建用户'} footer={
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} loading={saving}></Button>
<Button variant="ghost" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleCreate} loading={creating}></Button>
</>
}>
{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>}
{createError && <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">{createError}</div>}
<div className="space-y-4">
{!editing && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} />}
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
<Input label={editing ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
{editing && (editing.username === 'admin' || editing.username === 'localadmin') ? (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"></label>
<p className="text-sm text-slate-500 dark:text-slate-400 py-2">{editing.role === 'admin' ? '管理员' : '管理员(系统保留)'}</p>
</div>
) : (
<Select label="角色" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
)}
<Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} />
<Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} />
<Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
<Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
<Select label="角色" value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} options={roleOptions} />
</div>
</Modal>
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
<div className="flex justify-end gap-3 mt-4">
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除" footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="danger" onClick={handleDelete}></Button>
</div>
</>
}>
<p className="text-sm text-slate-600 dark:text-slate-400">{deleteTarget?.display_name}</p>
</Modal>
</div>
)

View File

@ -39,14 +39,14 @@ export async function POST(request: Request) {
).get(username) as { id: number; role: string } | undefined
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, display_name = ? WHERE id = ?')
.run(pwHash, displayName, existing.id)
db.prepare('UPDATE users SET password_hash = ?, display_name = ?, email = ? WHERE id = ?')
.run(pwHash, displayName, ldapResult.email || null, existing.id)
userId = existing.id
role = existing.role
} else {
db.prepare(
"INSERT INTO users (username, password_hash, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, pwHash, displayName)
"INSERT INTO users (username, password_hash, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(username, pwHash, displayName, ldapResult.email || null)
const created = db.prepare(
'SELECT id, role FROM users WHERE username = ?'
).get(username) as { id: number; role: string } | undefined

View File

@ -5,6 +5,21 @@ import { checkPermission } from '@/lib/permissions'
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
const { id } = await params
const user = db.prepare(`
SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?
`).get(id) as Record<string, unknown> | undefined
if (!user) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
return NextResponse.json({ user })
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
@ -41,7 +56,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
values.push(id)
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values)
const user = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
const user = db.prepare(`SELECT id, username, display_name, email, role, is_active, created_at, updated_at,
last_login_at,
CASE WHEN last_active_at IS NOT NULL AND datetime(last_active_at, '+5 minutes') > datetime('now', '+8 hours') THEN 1 ELSE 0 END AS is_online
FROM users WHERE id = ?`).get(id)
return NextResponse.json({ user })
} catch (e) {
const msg = e instanceof Error ? e.message : '更新用户失败'

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import db from '@/lib/db'
import { getSession } from '@/lib/auth'
import { checkPermission } from '@/lib/permissions'
import { ldapGetUserInfo } from '@/lib/ldap'
export async function POST() {
const session = await getSession()
if (!session) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!checkPermission(session.role, 'users:write')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
const users = db.prepare(
'SELECT id, username FROM users WHERE email IS NULL OR email = \'\''
).all() as { id: number; username: string }[]
let synced = 0
let failed = 0
for (const u of users) {
const info = await ldapGetUserInfo(u.username)
if (info?.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, u.id)
synced++
} else {
failed++
}
}
return NextResponse.json({ synced, failed, total: users.length })
}

View File

@ -17,16 +17,16 @@ export default function TopBar({ user }: TopBarProps) {
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div />
<div className="flex items-center gap-4">
<button onClick={toggleTheme} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title={theme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'}>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
{user && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<User size={16} />
<span>{user.display_name}</span>
</div>
<button onClick={handleLogout} className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" title="退出登录">
<button onClick={handleLogout} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="退出登录">
<LogOut size={18} />
</button>
</div>

View File

@ -1,8 +1,41 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { ReactNode, useEffect, useRef } from 'react'
interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; footer?: ReactNode }
export default function Modal({ open, onClose, title, children, footer }: ModalProps) {
useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = '' } }, [open])
const footerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
useEffect(() => {
if (!open) return
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') { onClose(); return }
if (!footerRef.current) return
const buttons = footerRef.current.querySelectorAll('button:not([disabled])')
if (buttons.length === 0) return
const currentIdx = Array.from(buttons).indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const next = e.key === 'ArrowRight'
? (currentIdx + 1) % buttons.length
: (currentIdx - 1 + buttons.length) % buttons.length
;(buttons[next] as HTMLButtonElement).focus()
} else if (e.key === 'Enter' && currentIdx >= 0) {
e.preventDefault()
;(document.activeElement as HTMLButtonElement).click()
}
}
document.addEventListener('keydown', handleKeyDown)
const t = setTimeout(() => {
const btn = footerRef.current?.querySelector('button:not([disabled])')
if (btn) (btn as HTMLButtonElement).focus()
}, 100)
return () => { document.removeEventListener('keydown', handleKeyDown); clearTimeout(t) }
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@ -15,7 +48,9 @@ export default function Modal({ open, onClose, title, children, footer }: ModalP
</div>
)}
<div className="p-6">{children}</div>
{footer && <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>}
{footer && (
<div ref={footerRef} className="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800">{footer}</div>
)}
</div>
</div>
)

View File

@ -40,7 +40,7 @@ export function verifySession(token: string): SessionPayload | null { return ver
// 统一获取当前会话:优先 tlyq_session共享 JWT回退 session_assets本地 JWT
import { cookies } from 'next/headers'
import { verifySharedJwt } from '@/lib/jwt'
import { ldapUserExists } from '@/lib/ldap'
import { ldapUserExists, ldapGetUserInfo } from '@/lib/ldap'
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies()
@ -57,16 +57,23 @@ export async function getSession(): Promise<SessionPayload | null> {
return null
}
const row = db.prepare(
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined
'SELECT id, username, role, email FROM users WHERE username = ? AND is_active = 1'
).get(sharedPayload.username) as { id: number; username: string; role: string; email: string | null } | undefined
if (row) {
if (!row.email) {
const info = await ldapGetUserInfo(sharedPayload.username)
if (info?.email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(info.email, row.id)
}
db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(row.id)
return { userId: row.id, username: row.username, role: row.role }
}
// SSO 免登录LLDAP 验证通过但本地无记录 → 自动创建viewer 角色)
const ldapInfo = await ldapGetUserInfo(sharedPayload.username)
const displayName = ldapInfo?.displayName || sharedPayload.displayName
const email = ldapInfo?.email ?? null
db.prepare(
"INSERT OR IGNORE INTO users (username, display_name, role, is_active, created_at, updated_at) VALUES (?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(sharedPayload.username, sharedPayload.displayName)
"INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))"
).run(sharedPayload.username, displayName, email)
const newRow = db.prepare(
'SELECT id, username, role FROM users WHERE username = ? AND is_active = 1'
).get(sharedPayload.username) as { id: number; username: string; role: string } | undefined

View File

@ -18,6 +18,7 @@ export interface LdapResult {
unreachable: boolean
username?: string
displayName?: string
email?: string
}
export async function ldapAuth(
@ -33,11 +34,13 @@ export async function ldapAuth(
const { searchEntries } = await client.search(LDAP_BASE_DN, {
scope: 'sub',
filter: `(uid=${username})`,
attributes: ['displayName'],
attributes: ['displayName', 'mail'],
timeLimit: 3,
})
const displayName = (searchEntries[0] as any)?.displayName || username
return { success: true, unreachable: false, username, displayName }
const entry = searchEntries[0] as any
const displayName = entry?.displayName || username
const email = entry?.mail || null
return { success: true, unreachable: false, username, displayName, email }
} catch {
return { success: true, unreachable: false, username, displayName: username }
}
@ -51,6 +54,22 @@ export async function ldapAuth(
}
}
// 从 LLDAP 获取用户信息displayName + email不可达返回 null
export async function ldapGetUserInfo(username: string): Promise<{ displayName: string; email: string | null } | null> {
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'
const adminPass = getLdapAdminPassword()
const client = new Client({ url: LDAP_URL, timeout: 5000 })
try {
await client.bind(adminDn, adminPass)
const { searchEntries } = await client.search(LDAP_BASE_DN, {
scope: 'sub', filter: `(uid=${username})`, attributes: ['displayName', 'mail'], timeLimit: 3,
})
const entry = searchEntries[0] as any
return entry ? { displayName: entry.displayName || username, email: entry.mail || null } : null
} catch { return null }
finally { await client.unbind() }
}
// Q1: 检查 LLDAP 中用户是否存在(用 admin bind 搜索,不在/不可达均返回 true 保证容错)
export async function ldapUserExists(username: string): Promise<boolean> {
const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'