feat: add hotel_admin (Системный администратор) and technician roles + protect hotel owner from deletion

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:39:48 +03:00
parent 943b97c200
commit d5267335a0
8 changed files with 128 additions and 53 deletions

View File

@@ -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';

View File

@@ -168,7 +168,7 @@ const auth: FastifyPluginAsync = async (fastify) => {
) )
await client.query( await client.query(
`INSERT INTO users (name, email, password_hash, role, hotel_id, phone, email_confirmed, confirmation_token, confirmation_sent_at) `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], [contact, email.toLowerCase().trim(), passwordHash, hotel.id, phone ?? null, confirmToken],
) )
await client.query('COMMIT') await client.query('COMMIT')

View File

@@ -42,7 +42,7 @@ const channels: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/channels/:id', '/api/hotels/:slug/channels/:id',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params
@@ -86,7 +86,7 @@ const channels: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/channels/:id/sync', '/api/hotels/:slug/channels/:id/sync',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params

View File

@@ -60,7 +60,7 @@ const rooms: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/rooms', '/api/hotels/:slug/rooms',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug } = request.params const { slug } = request.params
@@ -127,7 +127,7 @@ const rooms: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/rooms/:id', '/api/hotels/:slug/rooms/:id',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params
@@ -179,7 +179,7 @@ const rooms: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/rooms/:id', '/api/hotels/:slug/rooms/:id',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params

View File

@@ -21,7 +21,7 @@ const users: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/users', '/api/hotels/:slug/users',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug } = request.params const { slug } = request.params
@@ -46,7 +46,7 @@ const users: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/users', '/api/hotels/:slug/users',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug } = request.params const { slug } = request.params
@@ -58,10 +58,14 @@ const users: FastifyPluginAsync = async (fastify) => {
const { email, password, name, role = 'housekeeper', phone, position } = request.body const { email, password, name, role = 'housekeeper', phone, position } = request.body
// Managers cannot create other managers or super_admins // Role creation permissions
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security'] 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)) { 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) const passwordHash = await bcrypt.hash(password, 12)
@@ -90,7 +94,7 @@ const users: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/users/:id', '/api/hotels/:slug/users/:id',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params
@@ -100,6 +104,17 @@ const users: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) 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 updates: string[] = []
const values: unknown[] = [] const values: unknown[] = []
let idx = 1 let idx = 1
@@ -111,9 +126,14 @@ const users: FastifyPluginAsync = async (fastify) => {
updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++ updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++
} }
if (request.body.role) { 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 // 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' || 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))) { (request.user.role === 'manager' && managerAllowedRoles.includes(request.body.role))) {
updates.push(`role = $${idx}`); values.push(request.body.role); idx++ updates.push(`role = $${idx}`); values.push(request.body.role); idx++
} }
@@ -152,7 +172,7 @@ const users: FastifyPluginAsync = async (fastify) => {
'/api/hotels/:slug/users/:id', '/api/hotels/:slug/users/:id',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
async (request, reply) => { 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' }) return reply.code(403).send({ error: 'Forbidden' })
} }
const { slug, id } = request.params const { slug, id } = request.params
@@ -166,6 +186,15 @@ const users: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) 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( const { rowCount } = await db.query(
'DELETE FROM users WHERE id = $1 AND hotel_id = $2', 'DELETE FROM users WHERE id = $1 AND hotel_id = $2',
[id, hotelId], [id, hotelId],

View File

@@ -68,7 +68,7 @@ export async function seedIfEmpty() {
) )
await db.query( await db.query(
`INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES `INSERT INTO users (email, password_hash, name, role, hotel_id) VALUES
($1, $2, 'Артём Голомазов', 'manager', $3), ($1, $2, 'Артём Голомазов', 'hotel_admin', $3),
($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`, ($4, $2, 'Клавдия Иванова', 'housekeeper', $3)`,
['manager@grand-palace.ru', passwordHash, gp.id, ['manager@grand-palace.ru', passwordHash, gp.id,
'cleaner@grand-palace.ru'], 'cleaner@grand-palace.ru'],
@@ -126,6 +126,6 @@ export async function seedIfEmpty() {
console.log('[seed] ✅ Demo data seeded') console.log('[seed] ✅ Demo data seeded')
console.log('[seed] admin@hotelsync.io / demo (super_admin)') 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)') console.log('[seed] cleaner@grand-palace.ru / demo (housekeeper)')
} }

View File

@@ -2,7 +2,7 @@ export interface JwtPayload {
sub: string // user.id (UUID) sub: string // user.id (UUID)
email: string email: string
name: string name: string
role: 'manager' | 'housekeeper' | 'super_admin' role: 'hotel_admin' | 'manager' | 'housekeeper' | 'super_admin' | 'receptionist' | 'accountant' | 'security' | 'technician'
hotelId: string | null hotelId: string | null
hotelSlug: string | null hotelSlug: string | null
} }

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } 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, Lock, Check, X as XIcon, Wrench,
} 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'
@@ -12,7 +12,7 @@ import { Modal } from '../components/ui/Modal'
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
type StaffRole = 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' type StaffRole = 'hotel_admin' | 'hotel_manager' | 'housekeeper' | 'receptionist' | 'accountant' | 'security' | 'technician'
interface StaffUser { interface StaffUser {
id: string id: string
@@ -45,11 +45,13 @@ interface RolePermissions {
// ── Constants ────────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────────
const ROLE_META: Record<StaffRole, { label: string; color: string; icon: React.ElementType }> = { const ROLE_META: Record<StaffRole, { label: string; color: string; icon: React.ElementType }> = {
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 }, 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 }, 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 }, 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 }, 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 }, 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 = [ const AVATAR_COLORS = [
@@ -58,11 +60,13 @@ const AVATAR_COLORS = [
] ]
const DEFAULT_POSITIONS: Record<StaffRole, string[]> = { const DEFAULT_POSITIONS: Record<StaffRole, string[]> = {
hotel_admin: ['Генеральный директор', 'Владелец отеля', 'Управляющий директор'],
hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'], hotel_manager: ['Управляющий', 'Заместитель управляющего', 'Менеджер смены'],
receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'], receptionist: ['Старший администратор', 'Администратор', 'Ночной администратор'],
housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'], housekeeper: ['Старшая горничная', 'Горничная', 'Уборщик'],
accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'], accountant: ['Главный бухгалтер', 'Бухгалтер', 'Финансовый менеджер'],
security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'], security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'],
technician: ['Технический специалист', 'Электрик', 'Сантехник', 'Инженер по оборудованию'],
} }
const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [ const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
@@ -120,6 +124,13 @@ const allPerms = (v: boolean) =>
Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v])) Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v]))
const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
{
id: 'rp_hotel_admin',
name: 'Сис. администратор',
color: '#7C3AED',
isSystem: true,
permissions: allPerms(true),
},
{ {
id: 'rp_manager', id: 'rp_manager',
name: 'Менеджер', name: 'Менеджер',
@@ -169,6 +180,16 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
calendar: true, bookings: true, calendar: true, bookings: true,
}, },
}, },
{
id: 'rp_technician',
name: 'Тех. специалист',
color: '#D97706',
isSystem: true,
permissions: {
...allPerms(false),
housekeeping: true, rooms: true, maintenance: true,
},
},
] ]
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
@@ -178,6 +199,8 @@ function mapRole(r: string): StaffRole {
if (r === 'receptionist') return 'receptionist' if (r === 'receptionist') return 'receptionist'
if (r === 'accountant') return 'accountant' if (r === 'accountant') return 'accountant'
if (r === 'security') return 'security' if (r === 'security') return 'security'
if (r === 'technician') return 'technician'
if (r === 'hotel_admin') return 'hotel_admin'
return 'hotel_manager' return 'hotel_manager'
} }
@@ -324,8 +347,16 @@ function UserModal({
{/* Role */} {/* Role */}
<div> <div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Роль в системе *</label> <label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Роль в системе *</label>
{form.role === 'hotel_admin' ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
<Lock size={12} className="text-purple-500 dark:text-purple-400 shrink-0" />
<span className="text-xs font-medium text-purple-700 dark:text-purple-300">Сис. администратор роль нельзя изменить</span>
</div>
) : (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([id, meta]) => ( {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
.filter(([id]) => id !== 'hotel_admin')
.map(([id, meta]) => (
<button <button
key={id} key={id}
type="button" type="button"
@@ -342,6 +373,7 @@ function UserModal({
</button> </button>
))} ))}
</div> </div>
)}
</div> </div>
{/* Position */} {/* Position */}
@@ -706,11 +738,16 @@ export function UsersPage() {
const handleSave = async (u: StaffUser, password?: string) => { const handleSave = async (u: StaffUser, password?: string) => {
const fullName = `${u.firstName} ${u.lastName}`.trim() const fullName = `${u.firstName} ${u.lastName}`.trim()
const backendRole = u.role === 'housekeeper' ? 'housekeeper' const backendRoleMap: Record<StaffRole, string> = {
: u.role === 'receptionist' ? 'receptionist' hotel_admin: 'hotel_admin',
: u.role === 'accountant' ? 'accountant' hotel_manager: 'manager',
: u.role === 'security' ? 'security' receptionist: 'receptionist',
: 'manager' housekeeper: 'housekeeper',
accountant: 'accountant',
security: 'security',
technician: 'technician',
}
const backendRole = backendRoleMap[u.role] ?? 'manager'
try { try {
if (!u.id) { if (!u.id) {
const created = await api.users.create(slug, { const created = await api.users.create(slug, {
@@ -915,12 +952,14 @@ export function UsersPage() {
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
{u.role !== 'hotel_admin' && (
<button <button
onClick={() => setDeleteId(u.id)} onClick={() => setDeleteId(u.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
)}
</div> </div>
</td> </td>
</tr> </tr>