diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df2345..9c506ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/(app)/settings/api-keys/page.tsx b/src/app/(app)/settings/api-keys/page.tsx index 858f36e..4db6feb 100644 --- a/src/app/(app)/settings/api-keys/page.tsx +++ b/src/app/(app)/settings/api-keys/page.tsx @@ -21,6 +21,7 @@ export default function ApiKeysPage() { const [newKey, setNewKey] = useState(null) const [deleteTarget, setDeleteTarget] = useState(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
{perms.map(p => {p})}
}}, - { key: 'is_active', title: '状态', render: (r) => {r.is_active ? '启用' : '禁用'} }, + { key: 'is_active', title: '状态', render: (r) => }, { 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) => ( - + )}, ] return (
-

API Key 管理

+
+

API Key 管理

+

用于第三方系统调用资产系统 API

+
@@ -101,15 +113,20 @@ export default function ApiKeysPage() { }> - setName(e.target.value)} placeholder="例如:监控系统" /> +
+ setName(e.target.value)} placeholder="例如:监控系统" /> +

默认权限:assets:read(仅读取资产数据)

+ {error &&

{error}

} +
- setDeleteTarget(null)} title="确认删除"> -

确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法访问。

-
+ setDeleteTarget(null)} title="确认删除" footer={ + <> -
+ + }> +

确定要删除 API Key「{deleteTarget?.name}」吗?使用该 Key 的应用将无法访问。

) diff --git a/src/app/(app)/settings/roles/page.tsx b/src/app/(app)/settings/roles/page.tsx index 1fbccd8..bea861e 100644 --- a/src/app/(app)/settings/roles/page.tsx +++ b/src/app/(app)/settings/roles/page.tsx @@ -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(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[] = [ @@ -145,7 +145,7 @@ export default function RolesPage() {
{!BUILTIN_ROLES.includes(r.name) && ( - + )}
)}, @@ -206,6 +206,15 @@ export default function RolesPage() { + + setDeleteTarget(null)} title="确认删除" footer={ + <> + + + + }> +

确定要删除角色「{deleteTarget?.display_name}」吗?此操作不可撤销。

+
) } 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..dd0f356 --- /dev/null +++ b/src/app/(app)/settings/users/[id]/page.tsx @@ -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(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 = { 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 = roleOptions.find(r => r.value === user.role)?.label || user.role + + return ( +
+ + +
+
+

用户详情 — {user.display_name}

+

查看和管理用户信息

+
+ +
+ +
+ {[ + { label: '用户名', value: user.username }, + { label: '显示名称', value: user.display_name }, + { label: '邮箱', value: user.email || '-' }, + { label: '角色', value: {roleLabel} }, + { 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 }))} />} - setForm(p => ({ ...p, display_name: e.target.value }))} /> - setForm(p => ({ ...p, email: e.target.value }))} /> - setForm(p => ({ ...p, password: e.target.value }))} /> - {editing && (editing.username === 'admin' || editing.username === 'localadmin') ? ( -
- -

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

-
- ) : ( - setCreateForm(p => ({ ...p, username: e.target.value }))} /> + setCreateForm(p => ({ ...p, display_name: e.target.value }))} /> + setCreateForm(p => ({ ...p, email: e.target.value }))} /> + setCreateForm(p => ({ ...p, password: e.target.value }))} /> +