Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
} from '../lib/permissions.js'
|
||||
|
||||
describe('hasPermission hierarchy', () => {
|
||||
it('grants read via write/admin', () => {
|
||||
expect(hasPermission(['vps:vps:write'], 'vps:vps:read')).toBe(true)
|
||||
expect(hasPermission(['vps:vps:admin'], 'vps:vps:read')).toBe(true)
|
||||
expect(hasPermission(['vps:vps:admin'], 'vps:vps:write')).toBe(true)
|
||||
})
|
||||
|
||||
it('denies missing section', () => {
|
||||
expect(hasPermission(['vps:vps:read'], 'vps:settings:admin')).toBe(false)
|
||||
expect(hasPermission(['vps:vps:read'], 'vps:vps:write')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permissionForRequest', () => {
|
||||
it('maps vps CRUD', () => {
|
||||
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
|
||||
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
|
||||
})
|
||||
|
||||
it('maps sync and settings', () => {
|
||||
expect(permissionForRequest('POST', '/api/sync/acc-1')).toBe('vps:sync:write')
|
||||
expect(permissionForRequest('GET', '/api/settings')).toBe('vps:settings:admin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: vps:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) return true
|
||||
const parts = required.split(':')
|
||||
if (parts.length !== 3) return false
|
||||
const [app, section, action] = parts
|
||||
if (action === 'read') {
|
||||
return (
|
||||
granted.includes(`${app}:${section}:write`) ||
|
||||
granted.includes(`${app}:${section}:admin`)
|
||||
)
|
||||
}
|
||||
if (action === 'write') {
|
||||
return granted.includes(`${app}:${section}:admin`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[]
|
||||
match: (path: string) => boolean
|
||||
permission: string
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) => p.startsWith('/api/dashboard'),
|
||||
permission: 'vps:dashboard:read',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p === '/api/vps' ||
|
||||
p.startsWith('/api/vps/') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/data'),
|
||||
permission: 'vps:vps:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/vps') || p.startsWith('/api/projects'),
|
||||
permission: 'vps:vps:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/providers') ||
|
||||
p.startsWith('/api/provider-accounts'),
|
||||
permission: 'vps:accounts:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/providers') ||
|
||||
p.startsWith('/api/provider-accounts'),
|
||||
permission: 'vps:accounts:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/payments') ||
|
||||
p.startsWith('/api/balance-ledger') ||
|
||||
p.startsWith('/api/rates'),
|
||||
permission: 'vps:payments:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/payments') ||
|
||||
p.startsWith('/api/balance-ledger'),
|
||||
permission: 'vps:payments:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST'],
|
||||
match: (p) => p.startsWith('/api/sync'),
|
||||
permission: 'vps:sync:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/settings') ||
|
||||
p.startsWith('/api/backup') ||
|
||||
p.startsWith('/api/audit') ||
|
||||
p.startsWith('/api/migrate') ||
|
||||
p.startsWith('/api/notifications') ||
|
||||
p.startsWith('/api/app-switcher'),
|
||||
permission: 'vps:settings:admin',
|
||||
},
|
||||
]
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase()
|
||||
const pathname = path.split('?')[0] ?? path
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue
|
||||
if (rule.match(pathname)) return rule.permission
|
||||
}
|
||||
// Default: any authenticated vps user for unmatched /api/*
|
||||
if (pathname.startsWith('/api/')) return 'vps:dashboard:read'
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user