diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d71a4..a697bf3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/src/app/(app)/settings/api-keys/page.tsx b/src/app/(app)/settings/api-keys/page.tsx index f849aa0..0e36878 100644 --- a/src/app/(app)/settings/api-keys/page.tsx +++ b/src/app/(app)/settings/api-keys/page.tsx @@ -86,25 +86,25 @@ export default function ApiKeysPage() {
-

API Key 管理

+

API Key 管理

用于第三方系统调用工单系统 API

{newKey && ( -
-

+

+

API Key 已创建(仅显示一次,请妥善保存)

- + {newKey}
@@ -124,7 +124,7 @@ export default function ApiKeysPage() { const perms: string[] = JSON.parse(k.permissions) return ( - {k.name} + {k.name}
{perms.map(p => {p})} @@ -163,7 +163,7 @@ export default function ApiKeysPage() { {error &&

{error}

}
- +
@@ -173,7 +173,7 @@ export default function ApiKeysPage() { 确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法再访问此系统。

- +
diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx index ea74834..2b792c1 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -37,6 +37,7 @@ export default function RolesPage() { const [editRole, setEditRole] = useState(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() {
-

角色权限

-

角色与权限配置

+

角色权限

+

角色与权限配置

- +
{loading ? ( @@ -119,7 +122,7 @@ export default function RolesPage() {
{r.display_name} {BUILTIN_ROLES.includes(r.name) && ( - 内置 + 内置 )}
@@ -137,16 +140,21 @@ export default function RolesPage() { )} - setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'}> + setModalOpen(false)} title={editRole ? '编辑角色' : '新建角色'} footer={ + <> + + + + }>
{!editRole && setForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. supervisor" />} setForm(p => ({ ...p, display_name: e.target.value }))} />
-
+
{allPermissions.map(p => ( -
{error &&

{error}

} -
- - -
diff --git a/src/app/(app)/settings/users/[id]/page.tsx b/src/app/(app)/settings/users/[id]/page.tsx new file mode 100644 index 0000000..a680ccd --- /dev/null +++ b/src/app/(app)/settings/users/[id]/page.tsx @@ -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(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 = { 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
加载中...
+ if (!user) return
用户不存在
+ + const roleLabel: Record = { admin: '管理员', operator: '运维人员', viewer: '查看者' } + + return ( +
+ + +
+
+

用户详情 — {user.display_name}

+

查看和管理用户信息

+
+ +
+ +
+ {[ + { label: '用户名', value: user.username }, + { label: '显示名称', value: user.display_name }, + { label: '邮箱', value: user.email || '-' }, + { label: '角色', value: {roleLabel[user.role] || user.role} }, + { label: '状态', value: {user.is_active ? '启用' : '禁用'} }, + { label: '在线', value: {user.is_online ? '在线' : '离线'} }, + { label: '最后登录', value: user.last_login_at ? {user.last_login_at} : 从未登录 }, + { label: '创建时间', value: {user.created_at} }, + ].map(row => ( +
+ {row.label} + {row.value} +
+ ))} +
+ + setEditOpen(false)} title="编辑用户" footer={ + <> + + + + }> +
+ {error &&

{error}

} + setForm(p => ({ ...p, display_name: e.target.value }))} /> + setForm(p => ({ ...p, email: e.target.value }))} /> + setForm(p => ({ ...p, password: e.target.value }))} /> + {form.password && setForm(p => ({ ...p, password_confirm: e.target.value }))} />} + {user.username === 'admin' || user.username === 'localadmin' ? ( +
+ +

管理员(系统保留)

+
+ ) : ( + setForm(p => ({ ...p, username: e.target.value }))} required />} - setForm(p => ({ ...p, display_name: e.target.value }))} required /> - setForm(p => ({ ...p, password: e.target.value }))} /> - setForm(p => ({ ...p, email: e.target.value }))} /> - {editUser && (editUser.username === 'admin' || editUser.username === 'localadmin') ? ( -
- -

{editUser.role === 'admin' ? '管理员' : '管理员(系统保留)'}

-
- ) : ( - setCreateForm(p => ({ ...p, username: e.target.value }))} required /> + setCreateForm(p => ({ ...p, display_name: e.target.value }))} required /> + setCreateForm(p => ({ ...p, password: e.target.value }))} /> + setCreateForm(p => ({ ...p, email: e.target.value }))} /> + {options.map((o) => ())}
) } -interface ModalProps { open: boolean; onClose: () => void; title: string; children: ReactNode } -export function Modal({ open, onClose, title, children }: ModalProps) { - if (!open) return null - return (

{title}

{children}
) -} - interface TableProps { headers: string[]; children: ReactNode } export function Table({ headers, children }: TableProps) { - return (
{headers.map((h) => ())}{children}
{h}
) + return (
{headers.map((h) => ())}{children}
{h}
) } import { useState } from 'react' diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 249dea3..226189b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 { @@ -26,17 +26,24 @@ export async function getCurrentUser(): Promise { } 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 diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts index 262e057..097cf14 100644 --- a/src/lib/ldap.ts +++ b/src/lib/ldap.ts @@ -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 { const adminDn = process.env.LDAP_ADMIN_DN || 'uid=admin,ou=people,dc=tlyq,dc=ai'