fix(web): улучшить адаптивность сетки графиков и дашборда
Docker / build (push) Failing after 22s

Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
Denozordec
2026-07-18 13:26:16 +07:00
parent f8e13a4a72
commit 84cc800d54
16 changed files with 827 additions and 15 deletions
+6 -4
View File
@@ -15,19 +15,21 @@
"@cfdm/db": "workspace:*",
"@cfdm/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/jwt": "^10.2.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"fastify": "^5.6.1",
"better-sqlite3": "^11.10.0",
"cors": "^2.8.5",
"drizzle-orm": "^0.40.0",
"express": "^4.21.1",
"cors": "^2.8.5",
"fastify": "^5.6.1",
"fastify-plugin": "^6.0.0",
"sql.js": "^1.14.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.0",
"typescript": "^5.9.2",
"vitest": "^3.0.0"
+4
View File
@@ -26,6 +26,7 @@ import { notificationsRoutes } from './routes/notifications.js'
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
import { authPlugin } from './plugins/auth.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -44,6 +45,9 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(authPlugin)
app.get('/health', async () => ({ ok: true }))
await app.register(dataRoutes)
await app.register(vpsRoutes)
+31
View File
@@ -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')
})
})
+123
View File
@@ -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
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import Fastify from 'fastify'
import { authPlugin, loadAuthConfig } from '../plugins/auth.js'
describe('auth plugin (AUTH_REQUIRED)', () => {
const secret = 'test-secret-at-least-8'
const issuer = 'https://auth.shnt.top'
beforeAll(() => {
process.env.AUTH_REQUIRED = 'true'
process.env.AUTH_JWT_SECRET = secret
process.env.AUTH_ISSUER = issuer
})
afterAll(() => {
delete process.env.AUTH_REQUIRED
delete process.env.AUTH_JWT_SECRET
delete process.env.AUTH_ISSUER
})
it('loadAuthConfig reads env', () => {
const cfg = loadAuthConfig({
AUTH_REQUIRED: 'true',
AUTH_JWT_SECRET: secret,
AUTH_ISSUER: issuer,
})
expect(cfg.required).toBe(true)
expect(cfg.jwtSecret).toBe(secret)
})
it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => {
const app = Fastify()
await app.register(authPlugin)
app.get('/api/vps', async () => [{ id: '1' }])
app.post('/api/vps', async () => ({ ok: true }))
await app.ready()
const noAuth = await app.inject({ method: 'GET', url: '/api/vps' })
expect(noAuth.statusCode).toBe(401)
const tokenNoApp = app.jwt.sign(
{
sub: 'u1',
email: '[email protected]',
name: 'A',
apps: ['cfdm'],
permissions: ['vps:vps:read'],
iss: issuer,
},
{ expiresIn: '1h' },
)
const forbiddenApp = await app.inject({
method: 'GET',
url: '/api/vps',
headers: { authorization: `Bearer ${tokenNoApp}` },
})
expect(forbiddenApp.statusCode).toBe(403)
const readOnly = app.jwt.sign(
{
sub: 'u2',
email: '[email protected]',
name: 'R',
apps: ['vps'],
permissions: ['vps:vps:read'],
iss: issuer,
},
{ expiresIn: '1h' },
)
const okRead = await app.inject({
method: 'GET',
url: '/api/vps',
headers: { authorization: `Bearer ${readOnly}` },
})
expect(okRead.statusCode).toBe(200)
const denyWrite = await app.inject({
method: 'POST',
url: '/api/vps',
headers: { authorization: `Bearer ${readOnly}` },
payload: {},
})
expect(denyWrite.statusCode).toBe(403)
await app.close()
})
})
+161
View File
@@ -0,0 +1,161 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from '../lib/permissions.js'
export type AuthConfig = {
required: boolean
jwtSecret: string
issuer: string
}
declare module 'fastify' {
interface FastifyInstance {
authConfig: AuthConfig
}
interface FastifyRequest {
authUser?: AuthUser
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email: string
name: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
}
user: {
sub: string
email: string
name: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
}
}
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
export function loadAuthConfig(
env: NodeJS.ProcessEnv = process.env,
): AuthConfig {
const isProd = env.NODE_ENV === 'production'
return {
required: boolEnv(env.AUTH_REQUIRED, false),
jwtSecret:
env.AUTH_JWT_SECRET ??
env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me'),
issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top',
}
}
function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path.startsWith('/api/integrations/cfdm')) return true
return false
}
export const authPlugin = fp(async (app) => {
const config = loadAuthConfig()
app.decorate('authConfig', config)
if (!config.required) {
app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled')
return
}
if (!config.jwtSecret || config.jwtSecret.length < 8) {
throw new Error('AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true')
}
await app.register(fjwt, {
secret: config.jwtSecret,
verify: {
allowedIss: [config.issuer],
},
})
app.addHook('onRequest', async (request, reply) => {
if (isPublicPath(request.url)) return
if (!request.url.startsWith('/api/')) return
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const payload = request.user
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: []
if (!apps.includes('vps')) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Нет доступа к приложению VPS Tracker',
},
})
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, request.url)
if (required && !hasPermission(permissions, required)) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: `Недостаточно прав: ${required}`,
},
})
}
})
})
export async function requirePermission(
request: FastifyRequest,
reply: FastifyReply,
permission: string,
): Promise<void> {
const user = request.authUser
if (!user) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
if (!hasPermission(user.permissions, permission)) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: `Недостаточно прав: ${permission}`,
},
})
}
}