feat: 用户管理 UI 全面统一 + LDAP 邮箱同步 + 角色/Key 页面对齐
- 新增用户详情页,支持编辑(状态切换/密码确认)、键盘导航 - Modal/Button 拆分为独立文件,样式与 assets-ai 对齐 - LDAP 邮箱三路径同步:密码登录、SSO 免登录、批量同步接口 - 角色权限页:Badge 按角色分色、增加保存 loading、checkbox dark 样式 - API Key 页:成功提示颜色对齐、取消按钮 ghost 统一 - 全站字体/颜色一致:text-sm、dark mode 补全 - 删除确认统一改用 Modal 替换原生 confirm
This commit is contained in:
parent
703ed1a8d4
commit
5f0c312e9c
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,7 +2,15 @@
|
|||
|
||||
## 2026-05-18
|
||||
|
||||
- [修复] 用户管理"最后登录"时间不动态更新:SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`(此前仅密码登录路径更新,导致 SSO 用户始终显示"从未登录")
|
||||
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
||||
- [优化] 用户管理 UI 统一:Modal/Button 拆分为独立文件、删除改用 Modal 确认、TopBar 样式统一
|
||||
- [调整] 用户列表页去创建时间列,编辑功能移到详情页,仅保留删除按钮
|
||||
- [新增] LDAP 邮箱自动同步:密码登录/SSO 路径均从 LLDAP 同步 `mail` 到本地 `users.email`,增加 `/api/users/sync-emails` 批量同步接口
|
||||
- [新增] 编辑用户弹窗增加启用/禁用状态切换、新密码二次确认
|
||||
- [优化] 角色权限页面 UI 统一:角色 Badge 按角色区分颜色、增加保存 loading、checkbox dark mode 样式
|
||||
- [优化] API Key 页面 UI 统一:成功提示颜色对齐、取消按钮 ghost 统一
|
||||
- [优化] 全站字体大小和颜色统一:列表页/详情页 `text-sm` 一致、dark mode 颜色补全
|
||||
- [修复] 用户管理"最后登录"时间不动态更新:SSO 免登录、本地 JWT 会话验证路径现在也会更新 `last_login_at`
|
||||
|
||||
## 2026-05-15
|
||||
|
||||
|
|
|
|||
|
|
@ -86,25 +86,25 @@ export default function ApiKeysPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">API Key 管理</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">API Key 管理</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">用于第三方系统调用工单系统 API</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setCreateOpen(true); setNewKey(null) }}>
|
||||
<Plus size={16} className="mr-1" />创建 Key
|
||||
<Plus size={16} />创建 Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{newKey && (
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl">
|
||||
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-2">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">
|
||||
API Key 已创建(仅显示一次,请妥善保存)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-3 bg-white dark:bg-slate-800 rounded-lg border border-emerald-200 dark:border-emerald-700 text-sm font-mono break-all text-slate-800 dark:text-slate-200">
|
||||
<code className="flex-1 p-2 bg-white dark:bg-slate-800 rounded border border-green-200 dark:border-green-700 text-sm font-mono break-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyKey(newKey)}>
|
||||
{copied ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} />}
|
||||
{copied ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +124,7 @@ export default function ApiKeysPage() {
|
|||
const perms: string[] = JSON.parse(k.permissions)
|
||||
return (
|
||||
<tr key={k.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{k.name}</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-white">{k.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{perms.map(p => <Badge key={p} variant="default">{p}</Badge>)}
|
||||
|
|
@ -163,7 +163,7 @@ export default function ApiKeysPage() {
|
|||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleCreate} loading={saving}>创建</Button>
|
||||
<Button variant="secondary" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||||
<Button variant="ghost" onClick={() => { setCreateOpen(false); setError('') }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -173,7 +173,7 @@ export default function ApiKeysPage() {
|
|||
确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。
|
||||
</p>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default function RolesPage() {
|
|||
const [editRole, setEditRole] = useState<Role | null>(null)
|
||||
const [form, setForm] = useState({ name: '', display_name: '', permissions: [] as string[] })
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchRoles = () => {
|
||||
fetch('/api/roles').then(r => r.json()).then(d => { if (d.roles) setRoles(d.roles) }).catch(() => {}).finally(() => setLoading(false))
|
||||
|
|
@ -69,6 +70,7 @@ export default function RolesPage() {
|
|||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editRole) {
|
||||
const res = await fetch(`/api/roles/${editRole.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: form.display_name, permissions: form.permissions }) })
|
||||
|
|
@ -81,6 +83,7 @@ export default function RolesPage() {
|
|||
setModalOpen(false)
|
||||
fetchRoles()
|
||||
} catch { setError('操作失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
|
|
@ -102,10 +105,10 @@ export default function RolesPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">角色权限</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">角色权限</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">角色与权限配置</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} className="mr-1" />新建角色</Button>
|
||||
<Button size="sm" onClick={openCreate}><Plus size={16} />新建角色</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -119,7 +122,7 @@ export default function RolesPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<span>{r.display_name}</span>
|
||||
{BUILTIN_ROLES.includes(r.name) && (
|
||||
<Badge variant="info">内置</Badge>
|
||||
<Badge variant={r.name === 'admin' ? 'info' : r.name === 'operator' ? 'success' : 'default'}>内置</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -137,16 +140,21 @@ export default function RolesPage() {
|
|||
</Table>
|
||||
)}
|
||||
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}>
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} loading={saving}>{editRole ? '保存' : '创建'}</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{!editRole && <Input label="角色名(英文)" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />}
|
||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">权限</label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{allPermissions.map(p => (
|
||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300" />
|
||||
<label key={p.key} className="flex items-center gap-2 cursor-pointer py-1">
|
||||
<input type="checkbox" checked={form.permissions.includes(p.key) || form.permissions.includes('*')} onChange={() => togglePermission(p.key)} className="rounded border-slate-300 dark:border-slate-600" />
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{p.label}</span>
|
||||
<span className="text-xs text-slate-400">{p.key}</span>
|
||||
</label>
|
||||
|
|
@ -154,10 +162,6 @@ export default function RolesPage() {
|
|||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave}>{editRole ? '保存' : '创建'}</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { Button, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
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 [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)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchUser() }, [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 dark:text-slate-400">加载中...</div>
|
||||
if (!user) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">用户不存在</div>
|
||||
|
||||
const roleLabel: Record<string, string> = { admin: '管理员', operator: '运维人员', viewer: '查看者' }
|
||||
|
||||
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 variant="info">{roleLabel[user.role] || user.role}</Badge> },
|
||||
{ label: '状态', value: <Badge variant={user.is_active ? 'success' : 'danger'}>{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>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<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="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Button, Table, Badge, Modal, Input, Select } from '@/components/ui'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
|
|
@ -18,10 +19,11 @@ interface User {
|
|||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [form, setForm] = useState({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
const [error, setError] = useState('')
|
||||
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<User | null>(null)
|
||||
|
||||
const fetchUsers = () => {
|
||||
fetch('/api/users').then(r => r.json()).then(d => { if (d.users) setUsers(d.users) }).catch(() => {}).finally(() => setLoading(false))
|
||||
|
|
@ -29,42 +31,22 @@ export default function UsersPage() {
|
|||
|
||||
useEffect(() => { fetchUsers() }, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditUser(null)
|
||||
setForm({ username: '', password: '', display_name: '', email: '', role: 'viewer' })
|
||||
setError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (u: User) => {
|
||||
setEditUser(u)
|
||||
setForm({ username: u.username, password: '', display_name: u.display_name, email: u.email || '', role: u.role })
|
||||
setError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
const handleCreate = async () => {
|
||||
setCreateError(''); setCreating(true)
|
||||
try {
|
||||
if (editUser) {
|
||||
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/${editUser.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 || !form.display_name) { 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 || !createForm.display_name) { 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()
|
||||
} catch { setError('操作失败') }
|
||||
} catch { setCreateError('操作失败') } finally { setCreating(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此用户?')) return
|
||||
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) fetchUsers()
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
const res = await fetch(`/api/users/${deleteTarget.id}`, { method: 'DELETE' })
|
||||
if (res.ok) { setDeleteTarget(null); fetchUsers() }
|
||||
}
|
||||
|
||||
const handleToggleActive = async (u: User) => {
|
||||
|
|
@ -78,22 +60,24 @@ export default function UsersPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">用户管理</h1>
|
||||
<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={openCreate}><Plus size={16} className="mr-1" />新建用户</Button>
|
||||
<Button size="sm" onClick={() => { setCreateError(''); setCreateOpen(true) }}><Plus size={16} />新建用户</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '创建时间', '操作']}>
|
||||
<Table headers={['用户名', '显示名称', '邮箱', '角色', '状态', '在线', '最后登录', '操作']}>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{u.username}</td>
|
||||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{u.display_name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/settings/users/${u.id}`} className="text-blue-600 dark:text-blue-400 hover:underline font-medium">{u.username}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-900 dark:text-white font-medium">{u.display_name}</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.email || '-'}</td>
|
||||
<td className="px-4 py-3"><Badge variant="info">{roleLabel[u.role] || u.role}</Badge></td>
|
||||
<td className="px-4 py-3"><Badge variant={u.role === 'admin' ? 'info' : u.role === 'operator' ? 'success' : 'default'}>{roleLabel[u.role] || u.role}</Badge></td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => handleToggleActive(u)}>
|
||||
<Badge variant={u.is_active ? 'success' : 'danger'}>{u.is_active ? '启用' : '禁用'}</Badge>
|
||||
|
|
@ -102,16 +86,14 @@ export default function UsersPage() {
|
|||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${u.is_online ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'}`} />
|
||||
<span className="text-xs text-slate-500">{u.is_online ? '在线' : '离线'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{u.is_online ? '在线' : '离线'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400">从未登录</span>}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">{u.created_at || '-'}</td>
|
||||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400">{u.last_login_at ? u.last_login_at : <span className="text-slate-400 dark:text-slate-500">从未登录</span>}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={14} /></Button>
|
||||
{u.username !== 'admin' && u.username !== 'localadmin' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(u.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(u)}><Trash2 size={14} className="text-red-500" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -120,27 +102,30 @@ export default function UsersPage() {
|
|||
</Table>
|
||||
)}
|
||||
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editUser ? '编辑用户' : '新建用户'}>
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="新建用户" footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} loading={creating}>创建</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4">
|
||||
{!editUser && <Input label="用户名" value={form.username} onChange={e => setForm(p => ({ ...p, username: e.target.value }))} required />}
|
||||
<Input label="显示名称" value={form.display_name} onChange={e => setForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||
<Input label={editUser ? '新密码(留空不修改)' : '密码'} type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
{editUser && (editUser.username === 'admin' || editUser.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">{editUser.role === 'admin' ? '管理员' : '管理员(系统保留)'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))} />
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSave}>{editUser ? '保存' : '创建'}</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</div>
|
||||
<Input label="用户名" value={createForm.username} onChange={e => setCreateForm(p => ({ ...p, username: e.target.value }))} required />
|
||||
<Input label="显示名称" value={createForm.display_name} onChange={e => setCreateForm(p => ({ ...p, display_name: e.target.value }))} required />
|
||||
<Input label="密码" type="password" value={createForm.password} onChange={e => setCreateForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<Input label="邮箱" type="email" value={createForm.email} onChange={e => setCreateForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<Select label="角色" options={[{ value: 'viewer', label: '查看者' }, { value: 'operator', label: '运维人员' }, { value: 'admin', label: '管理员' }]} value={createForm.role} onChange={e => setCreateForm(p => ({ ...p, role: e.target.value }))} />
|
||||
{createError && <p className="text-sm text-red-500">{createError}</p>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,14 +42,14 @@ export async function POST(request: NextRequest) {
|
|||
).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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@ import { initDatabase } from '@/lib/db-schema'
|
|||
import { getCurrentUser, hashPassword } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
const row = 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 (!row) return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
return NextResponse.json({ user: row })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
initDatabase()
|
||||
|
|
@ -38,7 +55,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT id, username, display_name, email, role, is_active, created_at, updated_at FROM users WHERE id = ?').get(id)
|
||||
const updated = 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: updated })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '更新失败'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { initDatabase } from '@/lib/db-schema'
|
||||
import { getCurrentUser } from '@/lib/auth'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { ldapGetUserInfo } from '@/lib/ldap'
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
initDatabase()
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||
if (!hasPermission(user, 'users:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||
|
||||
const db = getDb()
|
||||
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 })
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
'use client'
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: Variant; size?: Size; loading?: boolean }
|
||||
const v: Record<Variant, string> = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
secondary: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800',
|
||||
}
|
||||
const s: Record<Size, string> = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant = 'primary', size = 'md', loading, children, className = '', disabled, ...props }, ref) => (
|
||||
<button ref={ref} disabled={disabled || loading} className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed ${v[variant]} ${s[size]} ${className}`} {...props}>
|
||||
{loading && <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
|
||||
{children}
|
||||
</button>
|
||||
))
|
||||
Button.displayName = 'Button'
|
||||
export default Button
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
'use client'
|
||||
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) {
|
||||
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">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg mx-4 max-h-[90vh] overflow-auto bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-xl">
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">{children}</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
'use client'
|
||||
import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg'; loading?: boolean }
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', children, loading, disabled, ...props }: ButtonProps) {
|
||||
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
const v = { primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm', secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700', danger: 'bg-red-600 text-white hover:bg-red-700', ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800' }
|
||||
const s = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
||||
return <button className={`${base} ${v[variant]} ${s[size]} ${className}`} disabled={disabled || loading} {...props}>{loading ? <span className="mr-2 inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> : null}{children}</button>
|
||||
}
|
||||
export { default as Modal } from './Modal'
|
||||
export { default as Button } from './Button'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
|
||||
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||
|
|
@ -19,15 +14,9 @@ export function Select({ label, options, className = '', ...props }: SelectProps
|
|||
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
|
||||
}
|
||||
|
||||
interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode }
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
if (!open) return null
|
||||
return (<div className="fixed inset-0 z-50 flex items-center justify-center"><div className="fixed inset-0 bg-black/50" onClick={onClose} /><div className="relative bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-800 w-full max-w-lg mx-4 max-h-[90vh] overflow-auto"><div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800"><h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3><button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button></div><div className="p-6">{children}</div></div></div>)
|
||||
}
|
||||
|
||||
interface TableProps { headers: string[]; children: ReactNode }
|
||||
export function Table({ headers, children }: TableProps) {
|
||||
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
||||
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
||||
}
|
||||
|
||||
import { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { createToken, verifyToken, type UserPayload } from './jwt'
|
|||
export { createToken, verifyToken, type UserPayload }
|
||||
|
||||
import { verifySharedJwt } from './jwt-shared'
|
||||
import { ldapUserExists } from './ldap'
|
||||
import { ldapUserExists, ldapGetUserInfo } from './ldap'
|
||||
import { getUserPermissions } from './permissions'
|
||||
|
||||
export async function getCurrentUser(): Promise<UserPayload | null> {
|
||||
|
|
@ -26,17 +26,24 @@ export async function getCurrentUser(): Promise<UserPayload | null> {
|
|||
}
|
||||
const db = getDb()
|
||||
const row = db.prepare(
|
||||
'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(sharedPayload.username) as UserPayload | undefined
|
||||
'SELECT id, username, display_name, role, email FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(sharedPayload.username) as (UserPayload & { 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)
|
||||
row.permissions = getUserPermissions(row.role)
|
||||
return row
|
||||
}
|
||||
// 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, display_name, role FROM users WHERE username = ? AND is_active = 1'
|
||||
).get(sharedPayload.username) as UserPayload | undefined
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue