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:
13
backend/migrations/080_role_permissions.sql
Normal file
13
backend/migrations/080_role_permissions.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -47,6 +47,7 @@ import paymentMethodsRoutes from './routes/paymentMethods'
|
|||||||
import paymentGatewaysRoutes from './routes/paymentGateways'
|
import paymentGatewaysRoutes from './routes/paymentGateways'
|
||||||
import publicWidgetRoutes from './routes/publicWidget'
|
import publicWidgetRoutes from './routes/publicWidget'
|
||||||
import yookassaWebhookRoutes from './routes/yookassaWebhook'
|
import yookassaWebhookRoutes from './routes/yookassaWebhook'
|
||||||
|
import rolePermissionsRoutes from './routes/role-permissions'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
import { initWebPush } from './push'
|
import { initWebPush } from './push'
|
||||||
@@ -147,6 +148,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(paymentGatewaysRoutes)
|
await fastify.register(paymentGatewaysRoutes)
|
||||||
await fastify.register(publicWidgetRoutes)
|
await fastify.register(publicWidgetRoutes)
|
||||||
await fastify.register(yookassaWebhookRoutes)
|
await fastify.register(yookassaWebhookRoutes)
|
||||||
|
await fastify.register(rolePermissionsRoutes)
|
||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
|||||||
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
|
||||||
@@ -54,12 +54,14 @@ import { PayDepositPage } from './pages/PayDepositPage'
|
|||||||
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
|
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
|
||||||
import { BookingConfirmPage } from './pages/BookingConfirmPage'
|
import { BookingConfirmPage } from './pages/BookingConfirmPage'
|
||||||
import { ModuleGuard } from './components/ModuleGuard'
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
import { RolePermissionsProvider } from './contexts/RolePermissionsContext'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ModulesProvider>
|
<ModulesProvider>
|
||||||
|
<RolePermissionsProvider>
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<AmenitiesProvider>
|
<AmenitiesProvider>
|
||||||
<BedTypesProvider>
|
<BedTypesProvider>
|
||||||
@@ -125,6 +127,7 @@ export default function App() {
|
|||||||
</BedTypesProvider>
|
</BedTypesProvider>
|
||||||
</AmenitiesProvider>
|
</AmenitiesProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
|
</RolePermissionsProvider>
|
||||||
</ModulesProvider>
|
</ModulesProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
|
import { useRolePermissions } from '../../contexts/RolePermissionsContext'
|
||||||
import { MODULES_DATA } from '../../data/modulesData'
|
import { MODULES_DATA } from '../../data/modulesData'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ function GroupHeader({
|
|||||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { statuses } = useModules()
|
const { statuses } = useModules()
|
||||||
|
const { can } = useRolePermissions()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@@ -128,21 +130,14 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
|
|
||||||
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
|
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
|
||||||
|
|
||||||
// Role-based permission map
|
// Maps module IDs whose IDs differ from permission keys
|
||||||
const ROLE_PERMS: Record<string, string[]> = {
|
const MODULE_PERM_KEY: Record<string, string> = {
|
||||||
hotel_admin: ['*'],
|
'room-service': 'room_service',
|
||||||
manager: ['*'],
|
'olap-reports': 'reports',
|
||||||
receptionist: ['calendar','bookings','guests','rooms','availability','housekeeping','room_service','rental','pos','reviews','reports'],
|
'website-builder':'website',
|
||||||
housekeeper: ['calendar','housekeeping','maintenance','rooms'],
|
'channel-manager':'channels',
|
||||||
accountant: ['calendar','reports','pos','discounts','tariffs','pricing','loyalty'],
|
|
||||||
security: ['calendar','bookings'],
|
|
||||||
technician: ['calendar','housekeeping','maintenance','rooms'],
|
|
||||||
}
|
|
||||||
const can = (permission: string) => {
|
|
||||||
const role = user?.role ?? 'housekeeper'
|
|
||||||
const perms = ROLE_PERMS[role] ?? []
|
|
||||||
return perms.includes('*') || perms.includes(permission)
|
|
||||||
}
|
}
|
||||||
|
const modulePermKey = (moduleId: string) => MODULE_PERM_KEY[moduleId] ?? moduleId
|
||||||
|
|
||||||
// Core modules with dedicated nav positions (not shown in generic module list)
|
// Core modules with dedicated nav positions (not shown in generic module list)
|
||||||
const CORE_MODULE_IDS = ['channel-manager', 'rental']
|
const CORE_MODULE_IDS = ['channel-manager', 'rental']
|
||||||
@@ -185,7 +180,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
basics: ['/calendar', '/bookings', '/guests', '/rooms', '/room-categories', '/availability'],
|
basics: ['/calendar', '/bookings', '/guests', '/rooms', '/room-categories', '/availability'],
|
||||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
management: ['/users', '/schedule', '/documents', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/payments', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
|
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/payments', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
|
||||||
devGroup: ['/api-docs'],
|
devGroup: ['/api-docs'],
|
||||||
}
|
}
|
||||||
@@ -370,7 +365,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" {...navItemProps} />}
|
{can('maintenance') && <NavItem to="/technical" icon={Zap} label="Тех. задачи" {...navItemProps} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeModuleItems.filter(m => can(m.id)).map(m => (
|
{activeModuleItems.filter(m => can(modulePermKey(m.id))).map(m => (
|
||||||
<NavItem
|
<NavItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
to={m.sidebarItem!.path}
|
to={m.sidebarItem!.path}
|
||||||
@@ -385,16 +380,17 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Управление ── */}
|
{/* ── Управление ── */}
|
||||||
{(can('users') || can('loyalty') || can('maintenance') || can('rooms') || (can('channels') && isModuleActive('channel-manager'))) && (
|
{(can('users') || can('documents') || can('loyalty') || can('maintenance') || can('floor_map') || (can('channels') && isModuleActive('channel-manager'))) && (
|
||||||
<>
|
<>
|
||||||
<GroupHeader label="Управление" isOpen={groups.management} onToggle={() => toggleGroup('management')} />
|
<GroupHeader label="Управление" isOpen={groups.management} onToggle={() => toggleGroup('management')} />
|
||||||
{groups.management && (
|
{groups.management && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" {...navItemProps} />}
|
{can('users') && <NavItem to="/users" icon={UserCog} label="Сотрудники" {...navItemProps} />}
|
||||||
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" {...navItemProps} />}
|
{can('users') && <NavItem to="/schedule" icon={CalendarClock} label="График работы" {...navItemProps} />}
|
||||||
|
{can('documents') && <NavItem to="/documents" icon={FileText} label="Документы" {...navItemProps} />}
|
||||||
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" {...navItemProps} />}
|
{can('loyalty') && <NavItem to="/loyalty" icon={Award} label="Лояльность" {...navItemProps} />}
|
||||||
{can('maintenance') && <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" {...navItemProps} />}
|
{can('maintenance') && <NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" {...navItemProps} />}
|
||||||
{can('rooms') && <NavItem to="/floor-map" icon={Map} label="План этажей" {...navItemProps} />}
|
{can('floor_map') && <NavItem to="/floor-map" icon={Map} label="План этажей" {...navItemProps} />}
|
||||||
{can('channels') && isModuleActive('channel-manager') && (
|
{can('channels') && isModuleActive('channel-manager') && (
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/channels"
|
to="/channels"
|
||||||
@@ -409,21 +405,21 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Настройки ── */}
|
{/* ── Настройки ── */}
|
||||||
{can('settings') && (
|
{(can('settings') || can('equipment') || can('wifi') || can('ttlock')) && (
|
||||||
<>
|
<>
|
||||||
<GroupHeader label="Настройки" isOpen={groups.settingsGroup} onToggle={() => toggleGroup('settingsGroup')} />
|
<GroupHeader label="Настройки" isOpen={groups.settingsGroup} onToggle={() => toggleGroup('settingsGroup')} />
|
||||||
{groups.settingsGroup && (
|
{groups.settingsGroup && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
|
{can('settings') && <NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />}
|
||||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
{can('settings') && <NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />}
|
||||||
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
{can('settings') && <NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />}
|
||||||
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
{can('equipment') && <NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />}
|
||||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
{can('wifi') && <NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />}
|
||||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
{can('ttlock') && <NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />}
|
||||||
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
|
{can('settings') && <NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />}
|
||||||
<NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />
|
{can('settings') && <NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />}
|
||||||
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
|
{can('settings') && <NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />}
|
||||||
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
|
{can('settings') && <NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
104
src/contexts/RolePermissionsContext.tsx
Normal file
104
src/contexts/RolePermissionsContext.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
// ── Default permissions for system roles (fallback if no DB data) ─────────────
|
||||||
|
|
||||||
|
export const DEFAULT_ROLE_PERMS: Record<string, string[]> = {
|
||||||
|
hotel_admin: ['*'],
|
||||||
|
manager: ['*'],
|
||||||
|
receptionist: [
|
||||||
|
'calendar', 'bookings', 'guests', 'rooms', 'availability',
|
||||||
|
'housekeeping', 'room_service', 'rental',
|
||||||
|
'pos', 'reviews', 'reports',
|
||||||
|
'documents', 'website',
|
||||||
|
],
|
||||||
|
housekeeper: ['calendar', 'housekeeping', 'maintenance', 'rooms'],
|
||||||
|
accountant: ['calendar', 'reports', 'pos', 'discounts', 'tariffs', 'pricing', 'loyalty', 'documents'],
|
||||||
|
security: ['calendar', 'bookings'],
|
||||||
|
technician: ['calendar', 'housekeeping', 'maintenance', 'rooms', 'floor_map', 'equipment', 'ttlock'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SavedRolePermission {
|
||||||
|
id: string
|
||||||
|
hotelId: string
|
||||||
|
roleKey: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
isSystem: boolean
|
||||||
|
permissions: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RolePermissionsContextValue {
|
||||||
|
/** Check if the current user has a given permission */
|
||||||
|
can: (permission: string) => boolean
|
||||||
|
/** Raw saved permissions for all roles (used by RolesTab editor) */
|
||||||
|
savedPermissions: SavedRolePermission[]
|
||||||
|
/** Re-fetch from API */
|
||||||
|
reload: () => Promise<void>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Context ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RolePermissionsContext = createContext<RolePermissionsContextValue>({
|
||||||
|
can: () => false,
|
||||||
|
savedPermissions: [],
|
||||||
|
reload: async () => {},
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function RolePermissionsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [savedPermissions, setSavedPermissions] = useState<SavedRolePermission[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!user?.hotelSlug) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.rolePermissions.list(user.hotelSlug)
|
||||||
|
setSavedPermissions(data)
|
||||||
|
} catch {
|
||||||
|
// silently fall back to defaults
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [user?.hotelSlug])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const can = useCallback((permission: string): boolean => {
|
||||||
|
const role = user?.role ?? 'housekeeper'
|
||||||
|
|
||||||
|
// super_admin: full access everywhere
|
||||||
|
if (role === 'super_admin') return true
|
||||||
|
|
||||||
|
// hotel_admin and manager: full access to all hotel modules
|
||||||
|
if (role === 'hotel_admin' || role === 'manager') return true
|
||||||
|
|
||||||
|
// Check saved permissions from DB for this role
|
||||||
|
const saved = savedPermissions.find(rp => rp.roleKey === role)
|
||||||
|
if (saved) {
|
||||||
|
return saved.permissions[permission] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to hardcoded defaults
|
||||||
|
const perms = DEFAULT_ROLE_PERMS[role] ?? []
|
||||||
|
return perms.includes('*') || perms.includes(permission)
|
||||||
|
}, [user?.role, savedPermissions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RolePermissionsContext.Provider value={{ can, savedPermissions, reload, loading }}>
|
||||||
|
{children}
|
||||||
|
</RolePermissionsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRolePermissions() {
|
||||||
|
return useContext(RolePermissionsContext)
|
||||||
|
}
|
||||||
@@ -302,6 +302,22 @@ export const api = {
|
|||||||
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
|
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Role Permissions ──────────────────────────────────────────────────────
|
||||||
|
rolePermissions: {
|
||||||
|
list: (slug: string) =>
|
||||||
|
req<import('../contexts/RolePermissionsContext').SavedRolePermission[]>(
|
||||||
|
'GET', `/api/hotels/${slug}/role-permissions`),
|
||||||
|
|
||||||
|
save: (slug: string, roleKey: string, data: {
|
||||||
|
name: string; color: string; isSystem: boolean; permissions: Record<string, boolean>
|
||||||
|
}) =>
|
||||||
|
req<import('../contexts/RolePermissionsContext').SavedRolePermission>(
|
||||||
|
'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data),
|
||||||
|
|
||||||
|
delete: (slug: string, roleKey: string) =>
|
||||||
|
req<void>('DELETE', `/api/hotels/${slug}/role-permissions/${roleKey}`),
|
||||||
|
},
|
||||||
|
|
||||||
// ── Schedule ──────────────────────────────────────────────────────────────
|
// ── Schedule ──────────────────────────────────────────────────────────────
|
||||||
schedule: {
|
schedule: {
|
||||||
list: (slug: string, from: string, to: string) =>
|
list: (slug: string, from: string, to: string) =>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
|
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
|
||||||
Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
|
Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
|
||||||
Lock, Check, X as XIcon, Wrench,
|
Lock, Check, X as XIcon, Wrench, Save, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useRolePermissions } from '../contexts/RolePermissionsContext'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Modal } from '../components/ui/Modal'
|
import { Modal } from '../components/ui/Modal'
|
||||||
@@ -76,8 +77,8 @@ const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
|
|||||||
{ key: 'calendar', label: 'Календарь', description: 'Просмотр и управление сеткой бронирований' },
|
{ key: 'calendar', label: 'Календарь', description: 'Просмотр и управление сеткой бронирований' },
|
||||||
{ key: 'bookings', label: 'Бронирования', description: 'Создание, редактирование, отмена броней' },
|
{ key: 'bookings', label: 'Бронирования', description: 'Создание, редактирование, отмена броней' },
|
||||||
{ key: 'guests', label: 'Гости', description: 'Карточки гостей, история, лояльность' },
|
{ key: 'guests', label: 'Гости', description: 'Карточки гостей, история, лояльность' },
|
||||||
{ key: 'rooms', label: 'Номерной фонд', description: 'Номера, категории, тарифы' },
|
{ key: 'rooms', label: 'Номерной фонд', description: 'Номера, категории, план этажей' },
|
||||||
{ key: 'availability',label: 'Доступность', description: 'Ограничения и стоп-продажи' },
|
{ key: 'availability', label: 'Доступность', description: 'Ограничения и стоп-продажи' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,12 +109,22 @@ const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
|
|||||||
{ key: 'website', label: 'Сайт и виджет', description: 'Сайт отеля и виджет бронирования' },
|
{ key: 'website', label: 'Сайт и виджет', description: 'Сайт отеля и виджет бронирования' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Технологии',
|
||||||
|
modules: [
|
||||||
|
{ key: 'floor_map', label: 'План этажей', description: 'Интерактивная карта этажей отеля' },
|
||||||
|
{ key: 'equipment', label: 'Оборудование', description: 'Управление инвентарём и оборудованием' },
|
||||||
|
{ key: 'wifi', label: 'WiFi авторизация', description: 'Captive portal, настройки WiFi-сети' },
|
||||||
|
{ key: 'ttlock', label: 'Электронные замки', description: 'Управление TTLock-замками и кодами' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: 'Администрирование',
|
group: 'Администрирование',
|
||||||
modules: [
|
modules: [
|
||||||
{ key: 'users', label: 'Сотрудники', description: 'Управление пользователями и ролями' },
|
{ key: 'users', label: 'Сотрудники', description: 'Управление пользователями и ролями' },
|
||||||
|
{ key: 'schedule', label: 'График работы', description: 'Расписание смен сотрудников' },
|
||||||
{ key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' },
|
{ key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' },
|
||||||
{ key: 'settings', label: 'Настройки', description: 'Настройки отеля и системы' },
|
{ key: 'settings', label: 'Настройки', description: 'Настройки отеля, оплат, системы' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -145,9 +156,10 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
...allPerms(false),
|
...allPerms(false),
|
||||||
calendar: true, bookings: true, guests: true, rooms: true,
|
calendar: true, bookings: true, guests: true, rooms: true, availability: true,
|
||||||
housekeeping: true, room_service: true, rental: true,
|
housekeeping: true, room_service: true, rental: true,
|
||||||
pos: true, reviews: true,
|
pos: true, reviews: true, reports: true,
|
||||||
|
documents: true, website: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -157,7 +169,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
...allPerms(false),
|
...allPerms(false),
|
||||||
housekeeping: true, rooms: true, maintenance: true,
|
calendar: true, housekeeping: true, rooms: true, maintenance: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,7 +179,9 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
...allPerms(false),
|
...allPerms(false),
|
||||||
|
calendar: true,
|
||||||
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
|
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
|
||||||
|
documents: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -187,7 +201,9 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
isSystem: true,
|
isSystem: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
...allPerms(false),
|
...allPerms(false),
|
||||||
|
calendar: true,
|
||||||
housekeeping: true, rooms: true, maintenance: true,
|
housekeeping: true, rooms: true, maintenance: true,
|
||||||
|
floor_map: true, equipment: true, ttlock: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -442,16 +458,63 @@ function UserModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hotel_admin always has all permissions — excluded from the editable list
|
||||||
|
const DEFAULT_EDITABLE = INITIAL_ROLE_PERMISSIONS.filter(r => r.id !== 'rp_hotel_admin')
|
||||||
|
|
||||||
// ── Roles & Permissions Tab ────────────────────────────────────────────────────
|
// ── Roles & Permissions Tab ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RolesTab() {
|
function RolesTab() {
|
||||||
// hotel_admin always has all permissions — excluded from the editable list
|
const { user: currentUser } = useAuth()
|
||||||
const editableRoles = INITIAL_ROLE_PERMISSIONS.filter(r => r.id !== 'rp_hotel_admin')
|
const { reload: reloadContext } = useRolePermissions()
|
||||||
const [roles, setRoles] = useState<RolePermissions[]>(editableRoles)
|
const slug = currentUser?.hotelSlug ?? ''
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string>(editableRoles[0].id)
|
|
||||||
|
const [roles, setRoles] = useState<RolePermissions[]>(DEFAULT_EDITABLE)
|
||||||
|
const [selectedRoleId, setSelectedRoleId] = useState<string>(DEFAULT_EDITABLE[0].id)
|
||||||
const [newRoleName, setNewRoleName] = useState('')
|
const [newRoleName, setNewRoleName] = useState('')
|
||||||
const [addingRole, setAddingRole] = useState(false)
|
const [addingRole, setAddingRole] = useState(false)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveOk, setSaveOk] = useState(false)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
|
||||||
|
// ── Load from API ──────────────────────────────────────────────────────────
|
||||||
|
const loadRoles = useCallback(async () => {
|
||||||
|
if (!slug) return
|
||||||
|
try {
|
||||||
|
const saved = await api.rolePermissions.list(slug)
|
||||||
|
if (saved.length > 0) {
|
||||||
|
// Merge: start from INITIAL defaults, override with saved data, append custom roles
|
||||||
|
const byKey = Object.fromEntries(saved.map(r => [r.roleKey, r]))
|
||||||
|
|
||||||
|
const merged: RolePermissions[] = DEFAULT_EDITABLE.map(def => {
|
||||||
|
const roleKey = def.id.replace('rp_', '')
|
||||||
|
const s = byKey[roleKey]
|
||||||
|
if (!s) return def
|
||||||
|
// Ensure all module keys are present (new modules default to false)
|
||||||
|
const fullPerms = { ...allPerms(false), ...s.permissions }
|
||||||
|
return { ...def, permissions: fullPerms }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom roles (not in system list)
|
||||||
|
const systemKeys = new Set(DEFAULT_EDITABLE.map(d => d.id.replace('rp_', '')))
|
||||||
|
const custom = saved
|
||||||
|
.filter(r => !r.isSystem && !systemKeys.has(r.roleKey))
|
||||||
|
.map(r => ({
|
||||||
|
id: `rp_${r.roleKey}`,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color,
|
||||||
|
isSystem: false,
|
||||||
|
permissions: { ...allPerms(false), ...r.permissions },
|
||||||
|
}))
|
||||||
|
|
||||||
|
setRoles([...merged, ...custom])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLoadError(true)
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
useEffect(() => { loadRoles() }, [loadRoles])
|
||||||
|
|
||||||
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? roles[0]
|
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? roles[0]
|
||||||
|
|
||||||
@@ -471,12 +534,38 @@ function RolesTab() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Save to API ────────────────────────────────────────────────────────────
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!slug || saving) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await Promise.all(roles.map(role => {
|
||||||
|
const roleKey = role.id.replace('rp_', '')
|
||||||
|
return api.rolePermissions.save(slug, roleKey, {
|
||||||
|
name: role.name,
|
||||||
|
color: role.color,
|
||||||
|
isSystem: role.isSystem,
|
||||||
|
permissions: role.permissions,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
setSaveOk(true)
|
||||||
|
setTimeout(() => setSaveOk(false), 2500)
|
||||||
|
await reloadContext()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save role permissions', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create / Delete ────────────────────────────────────────────────────────
|
||||||
const createRole = () => {
|
const createRole = () => {
|
||||||
const name = newRoleName.trim()
|
const name = newRoleName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
|
const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
|
||||||
|
const key = `custom_${Date.now()}`
|
||||||
const newRole: RolePermissions = {
|
const newRole: RolePermissions = {
|
||||||
id: `rp_${Date.now()}`,
|
id: `rp_${key}`,
|
||||||
name,
|
name,
|
||||||
color: colors[roles.length % colors.length],
|
color: colors[roles.length % colors.length],
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
@@ -488,7 +577,11 @@ function RolesTab() {
|
|||||||
setAddingRole(false)
|
setAddingRole(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRole = (id: string) => {
|
const deleteRole = async (id: string) => {
|
||||||
|
const roleKey = id.replace('rp_', '')
|
||||||
|
if (slug) {
|
||||||
|
try { await api.rolePermissions.delete(slug, roleKey) } catch { /* may not exist yet */ }
|
||||||
|
}
|
||||||
setRoles(prev => prev.filter(r => r.id !== id))
|
setRoles(prev => prev.filter(r => r.id !== id))
|
||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
if (selectedRoleId === id) {
|
if (selectedRoleId === id) {
|
||||||
@@ -500,6 +593,37 @@ function RolesTab() {
|
|||||||
const totalCount = ALL_MODULE_KEYS.length
|
const totalCount = ALL_MODULE_KEYS.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Save bar */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Настройте доступ к разделам для каждой роли
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||||
|
saveOk
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'btn-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? <Loader2 size={14} className="animate-spin" />
|
||||||
|
: saveOk
|
||||||
|
? <Check size={14} />
|
||||||
|
: <Save size={14} />}
|
||||||
|
{saving ? 'Сохранение…' : saveOk ? 'Сохранено!' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Не удалось загрузить настройки из базы данных. Отображаются значения по умолчанию.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-5 items-start">
|
<div className="flex flex-col sm:flex-row gap-5 items-start">
|
||||||
{/* Role list */}
|
{/* Role list */}
|
||||||
<div className="w-full sm:w-52 shrink-0">
|
<div className="w-full sm:w-52 shrink-0">
|
||||||
@@ -676,6 +800,8 @@ function RolesTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete role confirm */}
|
{/* Delete role confirm */}
|
||||||
{deleteId && (
|
{deleteId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user