feat: 权限细粒度拆分 + 前端按钮权限控制 + 角色管理优化

- 权限拆分:assets:write → assets:create + assets:import + assets:update
- 旧 assets:write 自动迁移(自定义角色),editor 默认权限同步更新
- API 层:create/import/update/batch/template 路由改用独立权限检查
- 前端:资产列表页/详情页按钮由权限驱动显隐(导入/模板/新增/编辑/删除)
- 新增/导入/编辑页面增加权限守卫,无权限重定向到资产列表
- 角色管理页权限选择列表同步新增三个权限选项
- 修复模板按钮链接指向错误的 404 页面
- editor/viewer 角色可编辑权限,仅 admin 强制同步默认值
- 三个内置角色均不可删除
- 部署到 txjp 服务器 (assets.tlyq.ai)
This commit is contained in:
gitadmin 2026-05-14 17:38:34 +08:00
parent 747fe293d5
commit dbc7600a59
9 changed files with 88 additions and 30 deletions

View File

@ -2,8 +2,13 @@
## 2026-05-14
- [修复] `next.config.ts` 添加 `ldapts``serverExternalPackages`(预防性修复),确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 SSO 自动创建用户失败issue-ai 已实际触发,参见 issue-ai CHANGELOG
- [调整] 全局证书切换Cloudflare Origin CA → Let's Encrypt`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名nginx 8 个站点配置同步更新
- [新增] 权限细粒度拆分:`assets:write` → `assets:create`(新增设备)、`assets:import`(导入设备)、`assets:update`(编辑设备),支持独立授权
- [新增] API 模板下载路由增加 `assets:import` 权限检查(原仅校验登录态)
- [优化] 角色权限选择列表同步新增三个权限选项(新增资产/导入资产/编辑资产)
- [修复] 资产列表页、详情页按钮无权限控制:导入、模板、新增设备、编辑、删除、批量编辑按钮现在由用户权限驱动显隐
- [修复] 新增/导入/编辑资产页面无权限守卫:无权限用户直接访问会被重定向到资产列表
- [修复] 资产列表页模板按钮链接指向 404`/assets/template` → `/api/assets/template`
- [调整] editor/viewer 角色权限允许自定义编辑(`db-schema.ts` 仅强制同步 admin三个内置角色均不可删除
## 2026-05-12

View File

@ -13,6 +13,15 @@ export default function EditAssetPage() {
const [asset, setAsset] = useState<Asset | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:update')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
useEffect(() => {
async function load() {
@ -35,6 +44,7 @@ export default function EditAssetPage() {
} finally { setSaving(false) }
}
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
if (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
if (!asset) return null

View File

@ -16,6 +16,7 @@ export default function AssetDetailPage() {
const [loading, setLoading] = useState(true)
const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
async function load() {
@ -26,6 +27,12 @@ export default function AssetDetailPage() {
load().finally(() => setLoading(false))
}, [params.id, router])
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
if (d.user?.permissions) setPermissions(d.user.permissions)
}).catch(() => {})
}, [])
async function handleDelete() {
setDeleting(true)
try {
@ -48,8 +55,12 @@ export default function AssetDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} /></Button></Link>
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} /></Button>
{(permissions.includes('*') || permissions.includes('assets:update')) && (
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} /></Button></Link>
)}
{(permissions.includes('*') || permissions.includes('assets:delete')) && (
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} /></Button>
)}
</div>
</div>

View File

@ -1,9 +1,24 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import AssetImport from '@/components/assets/AssetImport'
import { ArrowLeft } from 'lucide-react'
export default function ImportPage() {
const router = useRouter()
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:import')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
async function handleImport(file: File) {
const formData = new FormData()
formData.append('file', file)

View File

@ -1,5 +1,5 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import AssetForm from '@/components/assets/AssetForm'
@ -8,6 +8,15 @@ import { ArrowLeft } from 'lucide-react'
export default function NewAssetPage() {
const router = useRouter()
const [saving, setSaving] = useState(false)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => {
const perms: string[] = d.user?.permissions || []
if (!perms.includes('*') && !perms.includes('assets:create')) router.replace('/assets')
else setAuthorized(true)
}).catch(() => router.replace('/assets'))
}, [router])
async function handleSubmit(data: Record<string, unknown>) {
setSaving(true)
@ -22,6 +31,8 @@ export default function NewAssetPage() {
} finally { setSaving(false) }
}
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">...</div>
return (
<div className="space-y-4">
<div className="flex items-center gap-3">

View File

@ -365,8 +365,13 @@ export default function AssetsPage() {
// 是否有任何列筛选激活
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all')
const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected')
const hasWildcard = permissions.includes('*')
const canExportAll = hasWildcard || permissions.includes('assets:export:all')
const canExportSelected = hasWildcard || permissions.includes('assets:export:selected')
const canCreate = hasWildcard || permissions.includes('assets:create')
const canImport = hasWildcard || permissions.includes('assets:import')
const canUpdate = hasWildcard || permissions.includes('assets:update')
const canDelete = hasWildcard || permissions.includes('assets:delete')
return (
<div>
@ -379,15 +384,15 @@ export default function AssetsPage() {
</p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && permissions.includes('assets:update') && (
{canUpdate && selectedIds.size > 0 && (
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
<Button variant="secondary" size="sm"> {selectedIds.size} </Button>
</Link>
)}
{permissions.includes('assets:import') && (
{canImport && (
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} /></Button></Link>
)}
{permissions.includes('assets:import') && (
{canImport && (
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} /></Button></a>
)}
{canExportSelected && selectedIds.size > 0 && (
@ -396,7 +401,9 @@ export default function AssetsPage() {
{canExportAll && (
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
)}
<Link href="/assets/new"><Button size="sm"><Plus size={14} /></Button></Link>
{canCreate && (
<Link href="/assets/new"><Button size="sm"><Plus size={14} /></Button></Link>
)}
</div>
</div>
@ -559,14 +566,18 @@ export default function AssetsPage() {
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Eye size={16} />
</Link>
<Link href={`/assets/${row.id}/edit`}
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Edit size={16} />
</Link>
<button onClick={() => setDeleteTarget(row.id)}
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>
{canUpdate && (
<Link href={`/assets/${row.id}/edit`}
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<Edit size={16} />
</Link>
)}
{canDelete && (
<button onClick={() => setDeleteTarget(row.id)}
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>
)}
</div>
</td>
</tr>

View File

@ -17,7 +17,9 @@ interface Role {
const allPermissions = [
{ key: 'assets:read', label: '查看资产' },
{ key: 'assets:write', label: '编辑资产' },
{ key: 'assets:create', label: '新增资产' },
{ key: 'assets:import', label: '导入资产' },
{ key: 'assets:update', label: '编辑资产' },
{ key: 'assets:delete', label: '删除资产' },
{ key: 'assets:export:selected', label: '导出选中资产' },
{ key: 'assets:export:all', label: '导出全部资产' },

View File

@ -5,7 +5,7 @@ import { generateTemplateBuffer } from '@/lib/excel'
export async function GET() {
const payload = await getSession()
if (!payload) return NextResponse.json({ error: '未授权' }, { status: 401 })
if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 })
if (!checkPermission(payload.role, 'assets:import')) {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}

View File

@ -120,8 +120,8 @@ export function initDatabase() {
if (!existing) {
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
.run(role.name, role.display_name, role.permissions)
} else {
// 内置角色始终同步到最新默认权限
} else if (role.name === 'admin') {
// 仅 admin 角色始终同步到最新默认权限editor/viewer 允许用户自定义
db.prepare('UPDATE roles SET permissions = ? WHERE name = ?').run(role.permissions, role.name)
}
}
@ -137,13 +137,6 @@ export function initDatabase() {
if (perms.includes('*')) upgraded.push('assets:export:all')
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
}
}
// 迁移自定义角色中遗留的旧 assets:write 权限(拆分为 create/import/update
const allRoles2 = db.prepare('SELECT id, name, permissions FROM roles').all() as { id: number; name: string; permissions: string }[]
for (const r of allRoles2) {
if (builtinNames.has(r.name)) continue
const perms: string[] = JSON.parse(r.permissions)
if (perms.includes('assets:write')) {
const upgraded = perms.filter(p => p !== 'assets:write')
upgraded.push('assets:create', 'assets:import', 'assets:update')