feat: full role permissions system — new modules, API persistence, sidebar context

- Backend: migration 080_role_permissions table (hotel-scoped, upsert)
- Backend: routes GET/PUT/DELETE /api/hotels/:slug/role-permissions
- Frontend: RolePermissionsContext — loads saved perms from API, provides can()
- Frontend: Sidebar uses context can() instead of hardcoded ROLE_PERMS
- Frontend: fixed module ID→permKey mapping (room-service, olap-reports, website-builder)
- Frontend: Documents page added to Управление nav (was missing)
- Frontend: equipment/wifi/ttlock get own permission keys (not bundled under 'settings')
- Frontend: floor_map gets own permission key (not bundled under 'rooms')
- Frontend: new module group 'Технологии': wifi, equipment, ttlock, floor_map
- Frontend: 'schedule' added to Администрирование module group
- Updated INITIAL_ROLE_PERMISSIONS: receptionist+availability+reports+documents+website, accountant+documents, technician+floor_map+equipment+ttlock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 12:16:15 +03:00
parent ea58cffa88
commit 512e3c6659
8 changed files with 431 additions and 61 deletions

View File

@@ -0,0 +1,13 @@
-- Custom role permissions per hotel
CREATE TABLE IF NOT EXISTS role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
role_key VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(20) NOT NULL DEFAULT '#6B7280',
is_system BOOLEAN NOT NULL DEFAULT false,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(hotel_id, role_key)
);

View File

@@ -47,6 +47,7 @@ import paymentMethodsRoutes from './routes/paymentMethods'
import paymentGatewaysRoutes from './routes/paymentGateways'
import publicWidgetRoutes from './routes/publicWidget'
import yookassaWebhookRoutes from './routes/yookassaWebhook'
import rolePermissionsRoutes from './routes/role-permissions'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
import { initWebPush } from './push'
@@ -147,6 +148,7 @@ export async function buildApp() {
await fastify.register(paymentGatewaysRoutes)
await fastify.register(publicWidgetRoutes)
await fastify.register(yookassaWebhookRoutes)
await fastify.register(rolePermissionsRoutes)
await fastify.register(setupAgentWsRoute)
startJobs()

View File

@@ -0,0 +1,110 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugRoleParam = { Params: { slug: string; roleKey: string } }
const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
const canManage = (role: string) =>
['manager', 'hotel_admin', 'super_admin'].includes(role)
// ── GET /api/hotels/:slug/role-permissions ─────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/role-permissions',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT id, hotel_id, role_key, name, color, is_system, permissions, created_at, updated_at
FROM role_permissions
WHERE hotel_id = $1
ORDER BY is_system DESC, name`,
[hotelId],
)
return rows
},
)
// ── PUT /api/hotels/:slug/role-permissions/:roleKey ────────────────────────
fastify.put<SlugRoleParam & {
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean> }
}>(
'/api/hotels/:slug/role-permissions/:roleKey',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!canManage(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const { slug, roleKey } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { name, color, isSystem = false, permissions } = request.body
const { rows } = await db.query(
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (hotel_id, role_key) DO UPDATE
SET name = EXCLUDED.name,
color = EXCLUDED.color,
permissions = EXCLUDED.permissions,
updated_at = NOW()
RETURNING *`,
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions)],
)
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/role-permissions/:roleKey ─────────────────────
fastify.delete<SlugRoleParam>(
'/api/hotels/:slug/role-permissions/:roleKey',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
if (!canManage(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const { slug, roleKey } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
// System roles cannot be deleted
const { rows: existing } = await db.query(
'SELECT is_system FROM role_permissions WHERE hotel_id = $1 AND role_key = $2',
[hotelId, roleKey],
)
if (existing[0]?.is_system) {
return reply.code(403).send({ error: 'Нельзя удалить системную роль' })
}
const { rowCount } = await db.query(
'DELETE FROM role_permissions WHERE hotel_id = $1 AND role_key = $2',
[hotelId, roleKey],
)
if (!rowCount) return reply.code(404).send({ error: 'Role not found' })
return reply.code(204).send()
},
)
}
export default rolePermissionsRoute