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

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

View File

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

View File

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

View File

@@ -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],