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:
parent
747fe293d5
commit
dbc7600a59
|
|
@ -2,8 +2,13 @@
|
||||||
|
|
||||||
## 2026-05-14
|
## 2026-05-14
|
||||||
|
|
||||||
- [修复] `next.config.ts` 添加 `ldapts` 到 `serverExternalPackages`(预防性修复),确保 Next.js standalone 构建包含 LLDAP 客户端模块,避免 SSO 自动创建用户失败(issue-ai 已实际触发,参见 issue-ai CHANGELOG)
|
- [新增] 权限细粒度拆分:`assets:write` → `assets:create`(新增设备)、`assets:import`(导入设备)、`assets:update`(编辑设备),支持独立授权
|
||||||
- [调整] 全局证书切换:Cloudflare Origin CA → Let's Encrypt(`/etc/letsencrypt/live/www.tlyq.ai/`),覆盖全部 7 个子域名,nginx 8 个站点配置同步更新
|
- [新增] API 模板下载路由增加 `assets:import` 权限检查(原仅校验登录态)
|
||||||
|
- [优化] 角色权限选择列表同步新增三个权限选项(新增资产/导入资产/编辑资产)
|
||||||
|
- [修复] 资产列表页、详情页按钮无权限控制:导入、模板、新增设备、编辑、删除、批量编辑按钮现在由用户权限驱动显隐
|
||||||
|
- [修复] 新增/导入/编辑资产页面无权限守卫:无权限用户直接访问会被重定向到资产列表
|
||||||
|
- [修复] 资产列表页模板按钮链接指向 404(`/assets/template` → `/api/assets/template`)
|
||||||
|
- [调整] editor/viewer 角色权限允许自定义编辑(`db-schema.ts` 仅强制同步 admin),三个内置角色均不可删除
|
||||||
|
|
||||||
## 2026-05-12
|
## 2026-05-12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,15 @@ export default function EditAssetPage() {
|
||||||
const [asset, setAsset] = useState<Asset | null>(null)
|
const [asset, setAsset] = useState<Asset | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -35,6 +44,7 @@ export default function EditAssetPage() {
|
||||||
} finally { setSaving(false) }
|
} 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 (loading) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
if (!asset) return null
|
if (!asset) return null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export default function AssetDetailPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showDelete, setShowDelete] = useState(false)
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -26,6 +27,12 @@ export default function AssetDetailPage() {
|
||||||
load().finally(() => setLoading(false))
|
load().finally(() => setLoading(false))
|
||||||
}, [params.id, router])
|
}, [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() {
|
async function handleDelete() {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,8 +55,12 @@ export default function AssetDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{(permissions.includes('*') || permissions.includes('assets:update')) && (
|
||||||
<Link href={`/assets/${asset.id}/edit`}><Button variant="secondary" size="sm"><Edit size={16} />编辑</Button></Link>
|
<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>
|
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}><Trash2 size={16} />删除</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AssetImport from '@/components/assets/AssetImport'
|
import AssetImport from '@/components/assets/AssetImport'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
export default function ImportPage() {
|
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) {
|
async function handleImport(file: File) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import AssetForm from '@/components/assets/AssetForm'
|
import AssetForm from '@/components/assets/AssetForm'
|
||||||
|
|
@ -8,6 +8,15 @@ import { ArrowLeft } from 'lucide-react'
|
||||||
export default function NewAssetPage() {
|
export default function NewAssetPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [saving, setSaving] = useState(false)
|
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>) {
|
async function handleSubmit(data: Record<string, unknown>) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -22,6 +31,8 @@ export default function NewAssetPage() {
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!authorized) return <div className="py-20 text-center text-slate-500 dark:text-slate-400">验证权限中...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,13 @@ export default function AssetsPage() {
|
||||||
// 是否有任何列筛选激活
|
// 是否有任何列筛选激活
|
||||||
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
|
const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0)
|
||||||
|
|
||||||
const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all')
|
const hasWildcard = permissions.includes('*')
|
||||||
const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected')
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -379,15 +384,15 @@ export default function AssetsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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(',')}`}>
|
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
|
||||||
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{permissions.includes('assets:import') && (
|
{canImport && (
|
||||||
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
<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>
|
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} />模板</Button></a>
|
||||||
)}
|
)}
|
||||||
{canExportSelected && selectedIds.size > 0 && (
|
{canExportSelected && selectedIds.size > 0 && (
|
||||||
|
|
@ -396,7 +401,9 @@ export default function AssetsPage() {
|
||||||
{canExportAll && (
|
{canExportAll && (
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
|
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
|
||||||
)}
|
)}
|
||||||
|
{canCreate && (
|
||||||
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
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} />
|
<Eye size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
|
{canUpdate && (
|
||||||
<Link href={`/assets/${row.id}/edit`}
|
<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">
|
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} />
|
<Edit size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
<button onClick={() => setDeleteTarget(row.id)}
|
<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">
|
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} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ interface Role {
|
||||||
|
|
||||||
const allPermissions = [
|
const allPermissions = [
|
||||||
{ key: 'assets:read', label: '查看资产' },
|
{ 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:delete', label: '删除资产' },
|
||||||
{ key: 'assets:export:selected', label: '导出选中资产' },
|
{ key: 'assets:export:selected', label: '导出选中资产' },
|
||||||
{ key: 'assets:export:all', label: '导出全部资产' },
|
{ key: 'assets:export:all', label: '导出全部资产' },
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { generateTemplateBuffer } from '@/lib/excel'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const payload = await getSession()
|
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')) {
|
if (!checkPermission(payload.role, 'assets:import')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ export function initDatabase() {
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)')
|
||||||
.run(role.name, role.display_name, role.permissions)
|
.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)
|
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')
|
if (perms.includes('*')) upgraded.push('assets:export:all')
|
||||||
db.prepare('UPDATE roles SET permissions = ? WHERE id = ?').run(JSON.stringify(upgraded), r.id)
|
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')) {
|
if (perms.includes('assets:write')) {
|
||||||
const upgraded = perms.filter(p => p !== 'assets:write')
|
const upgraded = perms.filter(p => p !== 'assets:write')
|
||||||
upgraded.push('assets:create', 'assets:import', 'assets:update')
|
upgraded.push('assets:create', 'assets:import', 'assets:update')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue