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:
110
backend/src/routes/role-permissions.ts
Normal file
110
backend/src/routes/role-permissions.ts
Normal 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
|
||||
Reference in New Issue
Block a user