fix: 内置角色可编辑不可删除,修复种子迁移覆盖用户权限

- admin/operator/viewer 均为内置不可删除,但可编辑
- 移除 initDatabase() 中的权限合并迁移逻辑,避免每次启动覆盖用户自定义权限
- admin 在 API 层禁止删除
This commit is contained in:
gitadmin 2026-05-14 17:29:55 +08:00
parent 2d74f0a05b
commit 2697aaaa75
4 changed files with 24 additions and 20 deletions

View File

@ -2,6 +2,16 @@
## 2026-05-14 ## 2026-05-14
- [新增] 权限系统重构:工单细粒度权限(`tickets:create` 手动建单、`tickets:import` 导入、`tickets:export` 导出),`tickets:write` 缩窄为编辑/删除
- [新增] 报告三层权限:`reports:read`(查看列表+预览)、`reports:download`(下载)、`reports:create`(新建+生成文档+删除)
- [新增] `/api/auth/me` 返回 `permissions` 字段,前端根据权限数组控制按钮和导航显隐
- [新增] 角色管理 UI 权限项从 8 个扩展到 14 个(含 `api-keys:read/write`
- [修复] 角色管理页面admin/operator/viewer 标记为"内置"不可删除但可编辑admin 在 API 层禁止删除
- [修复] 报告 API 路由全部补全权限检查(此前列表/预览/下载/删除均无权限检查)
- [修复] 导出工单 API 新增 `tickets:export` 权限检查(此前仅验证登录态)
- [修复] POST 创建报告权限从错误的 `reports:read` 修正为 `reports:create`
- [修复] `reports:write` 全面替换为 `reports:create`
- [修复] 种子数据迁移逻辑不再每次启动覆盖用户自定义权限,仅首次安装时创建默认权限
- [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致) - [修复] SSO 新用户免登录失败:根页面 `getCurrentUser()` 改为直接 `redirect('/dashboard')`,由中间件统一处理共享 JWT 认证(与 assets-ai 行为一致)
- [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断 - [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`,确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 `ldapUserExists()` 因模块缺失失败导致 SSO 自动创建用户静默中断
- [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新 - [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新

View File

@ -11,6 +11,8 @@ interface Role {
created_at: string created_at: string
} }
const BUILTIN_ROLES = ['admin', 'operator', 'viewer']
const allPermissions = [ const allPermissions = [
{ key: 'tickets:read', label: '查看工单' }, { key: 'tickets:read', label: '查看工单' },
{ key: 'tickets:create', label: '手动建单' }, { key: 'tickets:create', label: '手动建单' },
@ -96,8 +98,6 @@ export default function RolesPage() {
} catch { return '无权限' } } catch { return '无权限' }
} }
const builtinRoles = ['admin', 'operator', 'viewer']
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -115,12 +115,19 @@ export default function RolesPage() {
{roles.map(r => ( {roles.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"> <tr key={r.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">{r.name}</td> <td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{r.name}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{r.display_name}</td> <td className="px-4 py-3 text-slate-700 dark:text-slate-300">
<div className="flex items-center gap-2">
<span>{r.display_name}</span>
{BUILTIN_ROLES.includes(r.name) && (
<Badge variant="info"></Badge>
)}
</div>
</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td> <td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{formatPermissions(r.permissions)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button> <Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={14} /></Button>
{!builtinRoles.includes(r.name) && ( {!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={() => handleDelete(r.id)}><Trash2 size={14} className="text-red-500" /></Button>
)} )}
</div> </div>

View File

@ -15,7 +15,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json() const body = await request.json()
const db = getDb() const db = getDb()
const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) const existing = db.prepare('SELECT * FROM roles WHERE id = ?').get(id) as any
if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 }) if (!existing) return NextResponse.json({ error: '角色不存在' }, { status: 404 })
const fields: string[] = [] const fields: string[] = []

View File

@ -63,20 +63,7 @@ export function initDatabase(): void {
{ name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' }, { name: 'viewer', display_name: '查看者', permissions: '["tickets:read","tickets:export","reports:read","reports:download"]' },
] ]
for (const r of roles) { for (const r of roles) {
const ex = db.prepare('SELECT id, permissions FROM roles WHERE name = ?').get(r.name) as { id: number; permissions: string } | undefined const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
if (!ex) { if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions)
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions)
} else {
// 迁移:更新已有角色的权限(追加新权限,保留已有自定义)
const newPerms = JSON.parse(r.permissions) as string[]
let existingPerms: string[] = []
try { existingPerms = JSON.parse(ex.permissions) } catch {}
if (!existingPerms.includes('*')) {
for (const p of newPerms) {
if (!existingPerms.includes(p)) existingPerms.push(p)
}
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(existingPerms), ex.id)
}
}
} }
} }