From d5267335a0b0af8678b6f0d71c6203306b01d911 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 26 Mar 2026 00:39:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20hotel=5Fadmin=20(=D0=A1=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80)?= =?UTF-8?q?=20and=20technician=20roles=20+=20protect=20hotel=20owner=20fro?= =?UTF-8?q?m=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 035: extend role constraint to include hotel_admin and technician; migrate existing manager users to hotel_admin - auth.ts: registration now assigns hotel_admin (not manager) to hotel owner - seed.ts: demo user manager@grand-palace.ru seeded as hotel_admin - types.ts: expand JwtPayload role union with all roles - users.ts: hotel_admin included in access checks; role creation/edit/delete rules enforced; hotel_admin users are undeletable and uneditable (non-super_admin); role cannot be set to hotel_admin via PATCH - rooms.ts / channels.ts: hotel_admin added to write-access checks - UsersPage.tsx: hotel_admin and technician added to StaffRole, ROLE_META, DEFAULT_POSITIONS, mapRole, backendRoleMap, INITIAL_ROLE_PERMISSIONS; delete button hidden for hotel_admin; role selector locked for hotel_admin users; hotel_admin excluded from new-user role selector Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/035_hotel_admin_technician.sql | 7 ++ backend/src/routes/auth.ts | 2 +- backend/src/routes/channels.ts | 4 +- backend/src/routes/rooms.ts | 6 +- backend/src/routes/users.ts | 45 +++++-- backend/src/seed.ts | 4 +- backend/src/types.ts | 2 +- src/pages/UsersPage.tsx | 111 ++++++++++++------ 8 files changed, 128 insertions(+), 53 deletions(-) create mode 100644 backend/migrations/035_hotel_admin_technician.sql diff --git a/backend/migrations/035_hotel_admin_technician.sql b/backend/migrations/035_hotel_admin_technician.sql new file mode 100644 index 0000000..bde6e9d --- /dev/null +++ b/backend/migrations/035_hotel_admin_technician.sql @@ -0,0 +1,7 @@ +-- Extend role constraint to include hotel_admin and technician +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users ADD CONSTRAINT users_role_check + CHECK (role IN ('hotel_admin', 'manager', 'housekeeper', 'super_admin', 'receptionist', 'accountant', 'security', 'technician')); + +-- Migrate existing manager users (hotel owners) to hotel_admin +UPDATE users SET role = 'hotel_admin' WHERE role = 'manager'; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index d9c234d..3389db2 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -168,7 +168,7 @@ const auth: FastifyPluginAsync = async (fastify) => { ) await client.query( `INSERT INTO users (name, email, password_hash, role, hotel_id, phone, email_confirmed, confirmation_token, confirmation_sent_at) - VALUES ($1, $2, $3, 'manager', $4, $5, false, $6, NOW())`, + VALUES ($1, $2, $3, 'hotel_admin', $4, $5, false, $6, NOW())`, [contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken], ) await client.query('COMMIT') diff --git a/backend/src/routes/channels.ts b/backend/src/routes/channels.ts index 00b3a7c..3d19b57 100644 --- a/backend/src/routes/channels.ts +++ b/backend/src/routes/channels.ts @@ -42,7 +42,7 @@ const channels: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/channels/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params @@ -86,7 +86,7 @@ const channels: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/channels/:id/sync', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts index f134b75..e17d716 100644 --- a/backend/src/routes/rooms.ts +++ b/backend/src/routes/rooms.ts @@ -60,7 +60,7 @@ const rooms: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/rooms', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug } = request.params @@ -127,7 +127,7 @@ const rooms: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/rooms/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params @@ -179,7 +179,7 @@ const rooms: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/rooms/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index d3bb213..9f042fe 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -21,7 +21,7 @@ const users: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/users', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug } = request.params @@ -46,7 +46,7 @@ const users: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/users', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug } = request.params @@ -58,10 +58,14 @@ const users: FastifyPluginAsync = async (fastify) => { const { email, password, name, role = 'housekeeper', phone, position } = request.body - // Managers cannot create other managers or super_admins - const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security'] + // Role creation permissions + const hotelAdminAllowedRoles = ['manager', 'housekeeper', 'receptionist', 'accountant', 'security', 'technician'] + const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security', 'technician'] + if (request.user.role === 'hotel_admin' && !hotelAdminAllowedRoles.includes(role)) { + return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' }) + } if (request.user.role === 'manager' && !managerAllowedRoles.includes(role)) { - return reply.code(403).send({ error: 'Managers cannot create managers or super_admins' }) + return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' }) } const passwordHash = await bcrypt.hash(password, 12) @@ -90,7 +94,7 @@ const users: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/users/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params @@ -100,6 +104,17 @@ const users: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + // Protect hotel_admin users — only super_admin can patch them + if (request.user.role !== 'super_admin') { + const { rows: targetRows } = await db.query( + 'SELECT role FROM users WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (targetRows[0]?.role === 'hotel_admin') { + return reply.code(403).send({ error: 'Нельзя редактировать системного администратора' }) + } + } + const updates: string[] = [] const values: unknown[] = [] let idx = 1 @@ -111,9 +126,14 @@ const users: FastifyPluginAsync = async (fastify) => { updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++ } if (request.body.role) { + // Cannot set role to hotel_admin via this endpoint + if (request.body.role === 'hotel_admin') { + return reply.code(403).send({ error: 'Нельзя назначить роль системного администратора' }) + } // Managers can change role but not to manager/super_admin - const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security'] + const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security', 'technician'] if (request.user.role === 'super_admin' || + (request.user.role === 'hotel_admin' && managerAllowedRoles.concat(['manager']).includes(request.body.role)) || (request.user.role === 'manager' && managerAllowedRoles.includes(request.body.role))) { updates.push(`role = $${idx}`); values.push(request.body.role); idx++ } @@ -152,7 +172,7 @@ const users: FastifyPluginAsync = async (fastify) => { '/api/hotels/:slug/users/:id', { onRequest: [fastify.authenticate] }, async (request, reply) => { - if (!['manager', 'super_admin'].includes(request.user.role)) { + if (!['manager', 'hotel_admin', 'super_admin'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const { slug, id } = request.params @@ -166,6 +186,15 @@ const users: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + // Check if target user is hotel_admin — cannot be deleted + const { rows: targetRows } = await db.query( + 'SELECT role FROM users WHERE id = $1 AND hotel_id = $2', + [id, hotelId], + ) + if (targetRows[0]?.role === 'hotel_admin') { + return reply.code(403).send({ error: 'Нельзя удалить системного администратора' }) + } + const { rowCount } = await db.query( 'DELETE FROM users WHERE id = $1 AND hotel_id = $2', [id, hotelId], diff --git a/backend/src/seed.ts b/backend/src/seed.ts index ac59dd5..b27264d 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -68,7 +68,7 @@ export async function seedIfEmpty() { ) await db.query( `INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES - ($1, $2, 'Артём Голомазов', 'manager', $3), + ($1, $2, 'Артём Голомазов', 'hotel_admin', $3), ($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`, ['manager@grand-palace.ru', passwordHash, gp.id, 'cleaner@grand-palace.ru'], @@ -126,6 +126,6 @@ export async function seedIfEmpty() { console.log('[seed] ✅ Demo data seeded') console.log('[seed] admin@hotelsync.io / demo (super_admin)') - console.log('[seed] manager@grand-palace.ru / demo (manager)') + console.log('[seed] manager@grand-palace.ru / demo (hotel_admin)') console.log('[seed] cleaner@grand-palace.ru / demo (housekeeper)') } diff --git a/backend/src/types.ts b/backend/src/types.ts index 14c17af..8b7d24b 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -2,7 +2,7 @@ export interface JwtPayload { sub: string // user.id (UUID) email: string name: string - role: 'manager' | 'housekeeper' | 'super_admin' + role: 'hotel_admin' | 'manager' | 'housekeeper' | 'super_admin' | 'receptionist' | 'accountant' | 'security' | 'technician' hotelId: string | null hotelSlug: string | null } diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index f9af9f4..d22f9cb 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { Plus, Pencil, Trash2, Search, Shield, User as UserIcon, Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle, - Lock, Check, X as XIcon, + Lock, Check, X as XIcon, Wrench, } from 'lucide-react' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' @@ -12,7 +12,7 @@ import { Modal } from '../components/ui/Modal' // ── Types ────────────────────────────────────────────────────────────────────── -type StaffRole = 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' +type StaffRole = 'hotel_admin' | 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' | 'technician' interface StaffUser { id: string @@ -45,11 +45,13 @@ interface RolePermissions { // ── Constants ────────────────────────────────────────────────────────────────── const ROLE_META: Record = { - hotel_manager: { label: 'Менеджер', color: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300', icon: Shield }, - receptionist: { label: 'Ресепшн', color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', icon: UserIcon }, - housekeeper: { label: 'Горничная', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', icon: Sparkles }, - accountant: { label: 'Бухгалтер', color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', icon: UserIcon }, - security: { label: 'Охрана', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Shield }, + hotel_admin: { label: 'Сис. администратор', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', icon: Shield }, + hotel_manager: { label: 'Менеджер', color: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300', icon: Shield }, + receptionist: { label: 'Ресепшн', color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', icon: UserIcon }, + housekeeper: { label: 'Горничная', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', icon: Sparkles }, + accountant: { label: 'Бухгалтер', color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', icon: UserIcon }, + security: { label: 'Охрана', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Shield }, + technician: { label: 'Тех. специалист', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', icon: Wrench }, } const AVATAR_COLORS = [ @@ -58,11 +60,13 @@ const AVATAR_COLORS = [ ] const DEFAULT_POSITIONS: Record = { + hotel_admin: ['Генеральный директор', 'Владелец отеля', 'Управляющий директор'], hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'], receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'], housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'], accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'], security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'], + technician: ['Технический специалист', 'Электрик', 'Сантехник', 'Инженер по оборудованию'], } const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [ @@ -120,6 +124,13 @@ const allPerms = (v: boolean) => Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v])) const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ + { + id: 'rp_hotel_admin', + name: 'Сис. администратор', + color: '#7C3AED', + isSystem: true, + permissions: allPerms(true), + }, { id: 'rp_manager', name: 'Менеджер', @@ -169,6 +180,16 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ calendar: true, bookings: true, }, }, + { + id: 'rp_technician', + name: 'Тех. специалист', + color: '#D97706', + isSystem: true, + permissions: { + ...allPerms(false), + housekeeping: true, rooms: true, maintenance: true, + }, + }, ] // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -178,6 +199,8 @@ function mapRole(r: string): StaffRole { if (r === 'receptionist') return 'receptionist' if (r === 'accountant') return 'accountant' if (r === 'security') return 'security' + if (r === 'technician') return 'technician' + if (r === 'hotel_admin') return 'hotel_admin' return 'hotel_manager' } @@ -324,24 +347,33 @@ function UserModal({ {/* Role */}
-
- {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([id, meta]) => ( - - ))} -
+ {form.role === 'hotel_admin' ? ( +
+ + Сис. администратор — роль нельзя изменить +
+ ) : ( +
+ {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]) + .filter(([id]) => id !== 'hotel_admin') + .map(([id, meta]) => ( + + ))} +
+ )}
{/* Position */} @@ -706,11 +738,16 @@ export function UsersPage() { const handleSave = async (u: StaffUser, password?: string) => { const fullName = `${u.firstName} ${u.lastName}`.trim() - const backendRole = u.role === 'housekeeper' ? 'housekeeper' - : u.role === 'receptionist' ? 'receptionist' - : u.role === 'accountant' ? 'accountant' - : u.role === 'security' ? 'security' - : 'manager' + const backendRoleMap: Record = { + hotel_admin: 'hotel_admin', + hotel_manager: 'manager', + receptionist: 'receptionist', + housekeeper: 'housekeeper', + accountant: 'accountant', + security: 'security', + technician: 'technician', + } + const backendRole = backendRoleMap[u.role] ?? 'manager' try { if (!u.id) { const created = await api.users.create(slug, { @@ -915,12 +952,14 @@ export function UsersPage() { > - + {u.role !== 'hotel_admin' && ( + + )}