fix: persist custom roles immediately, show them in user modal
- createRole() now calls api.rolePermissions.save() on creation so the role survives a page refresh (was only in local state before) - UserModal now renders custom roles from savedPermissions context alongside the system role buttons - mapRole() passes custom_* role keys through unchanged - handleSave falls back to u.role for custom roles (no backendRoleMap entry) - Table badge handles missing ROLE_META entry for custom roles - DB migration 082: removes users.role CHECK constraint so custom role keys can be stored in the users table - Backend POST/PATCH: allow custom_* roles through role allowlist checks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
backend/migrations/082_users_custom_roles.sql
Normal file
2
backend/migrations/082_users_custom_roles.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove role CHECK constraint so custom role keys (e.g. custom_123) can be stored
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
@@ -59,12 +59,13 @@ 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
|
||||||
|
|
||||||
// Role creation permissions
|
// Role creation permissions
|
||||||
|
const isCustomRole = (r: string) => r.startsWith('custom_')
|
||||||
const hotelAdminAllowedRoles = ['manager', 'housekeeper', 'receptionist', 'accountant', 'security', 'technician']
|
const hotelAdminAllowedRoles = ['manager', 'housekeeper', 'receptionist', 'accountant', 'security', 'technician']
|
||||||
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security', 'technician']
|
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security', 'technician']
|
||||||
if (request.user.role === 'hotel_admin' && !hotelAdminAllowedRoles.includes(role)) {
|
if (request.user.role === 'hotel_admin' && !hotelAdminAllowedRoles.includes(role) && !isCustomRole(role)) {
|
||||||
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
||||||
}
|
}
|
||||||
if (request.user.role === 'manager' && !managerAllowedRoles.includes(role)) {
|
if (request.user.role === 'manager' && !managerAllowedRoles.includes(role) && !isCustomRole(role)) {
|
||||||
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +132,11 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(403).send({ error: 'Нельзя назначить роль системного администратора' })
|
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 isCustomRole = (r: string) => r.startsWith('custom_')
|
||||||
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security', 'technician']
|
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 === 'hotel_admin' && (managerAllowedRoles.concat(['manager']).includes(request.body.role) || isCustomRole(request.body.role))) ||
|
||||||
(request.user.role === 'manager' && managerAllowedRoles.includes(request.body.role))) {
|
(request.user.role === 'manager' && (managerAllowedRoles.includes(request.body.role) || isCustomRole(request.body.role)))) {
|
||||||
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
|
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,12 +242,13 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function mapRole(r: string): StaffRole {
|
function mapRole(r: string): StaffRole {
|
||||||
if (r === 'housekeeper') return 'housekeeper'
|
if (r === 'housekeeper') return 'housekeeper'
|
||||||
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 === 'technician') return 'technician'
|
||||||
if (r === 'hotel_admin') return 'hotel_admin'
|
if (r === 'hotel_admin') return 'hotel_admin'
|
||||||
|
if (r.startsWith('custom_')) return r as StaffRole
|
||||||
return 'hotel_manager'
|
return 'hotel_manager'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +282,8 @@ function UserModal({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (u: StaffUser, password?: string) => void
|
onSave: (u: StaffUser, password?: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { savedPermissions } = useRolePermissions()
|
||||||
|
const customRoles = savedPermissions.filter(r => !r.isSystem)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: user?.firstName ?? '',
|
firstName: user?.firstName ?? '',
|
||||||
lastName: user?.lastName ?? '',
|
lastName: user?.lastName ?? '',
|
||||||
@@ -320,7 +323,7 @@ function UserModal({
|
|||||||
}, password || undefined)
|
}, password || undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestions = DEFAULT_POSITIONS[form.role]
|
const suggestions = DEFAULT_POSITIONS[form.role as keyof typeof DEFAULT_POSITIONS] ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -400,25 +403,47 @@ function UserModal({
|
|||||||
<span className="text-xs font-medium text-purple-700 dark:text-purple-300">Сис. администратор — роль нельзя изменить</span>
|
<span className="text-xs font-medium text-purple-700 dark:text-purple-300">Сис. администратор — роль нельзя изменить</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="space-y-2">
|
||||||
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
|
<div className="grid grid-cols-3 gap-2">
|
||||||
.filter(([id]) => id !== 'hotel_admin')
|
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
|
||||||
.map(([id, meta]) => (
|
.filter(([id]) => id !== 'hotel_admin')
|
||||||
<button
|
.map(([id, meta]) => (
|
||||||
key={id}
|
<button
|
||||||
type="button"
|
key={id}
|
||||||
onClick={() => { set('role', id); set('position', '') }}
|
type="button"
|
||||||
className={cn(
|
onClick={() => { set('role', id); set('position', '') }}
|
||||||
'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-xs font-medium border text-left transition-colors',
|
className={cn(
|
||||||
form.role === id
|
'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-xs font-medium border text-left transition-colors',
|
||||||
? 'bg-brand-600 border-brand-600 text-white'
|
form.role === id
|
||||||
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
|
? 'bg-brand-600 border-brand-600 text-white'
|
||||||
)}
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
|
||||||
>
|
)}
|
||||||
<meta.icon size={12} />
|
>
|
||||||
{meta.label}
|
<meta.icon size={12} />
|
||||||
</button>
|
{meta.label}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{customRoles.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{customRoles.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.roleKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { set('role', r.roleKey as StaffRole); set('position', '') }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-xs font-medium border text-left transition-colors',
|
||||||
|
form.role === r.roleKey
|
||||||
|
? 'bg-brand-600 border-brand-600 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: r.color }} />
|
||||||
|
{r.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -598,7 +623,7 @@ function RolesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Create / Delete ────────────────────────────────────────────────────────
|
// ── Create / Delete ────────────────────────────────────────────────────────
|
||||||
const createRole = () => {
|
const createRole = async () => {
|
||||||
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']
|
||||||
@@ -614,6 +639,21 @@ function RolesTab() {
|
|||||||
setSelectedRoleId(newRole.id)
|
setSelectedRoleId(newRole.id)
|
||||||
setNewRoleName('')
|
setNewRoleName('')
|
||||||
setAddingRole(false)
|
setAddingRole(false)
|
||||||
|
// Persist immediately so the role survives a page refresh
|
||||||
|
if (slug) {
|
||||||
|
try {
|
||||||
|
await api.rolePermissions.save(slug, key, {
|
||||||
|
name: newRole.name,
|
||||||
|
color: newRole.color,
|
||||||
|
isSystem: false,
|
||||||
|
permissions: newRole.permissions,
|
||||||
|
homePage: null,
|
||||||
|
})
|
||||||
|
await reloadContext()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new role', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRole = async (id: string) => {
|
const deleteRole = async (id: string) => {
|
||||||
@@ -907,6 +947,7 @@ type PageTab = typeof PAGE_TABS[number]['id']
|
|||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
|
const { savedPermissions } = useRolePermissions()
|
||||||
const slug = currentUser?.hotelSlug ?? ''
|
const slug = currentUser?.hotelSlug ?? ''
|
||||||
const [tab, setTab] = useState<PageTab>('staff')
|
const [tab, setTab] = useState<PageTab>('staff')
|
||||||
const [users, setUsers] = useState<StaffUser[]>([])
|
const [users, setUsers] = useState<StaffUser[]>([])
|
||||||
@@ -937,7 +978,7 @@ 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 backendRoleMap: Record<StaffRole, string> = {
|
const backendRoleMap: Record<string, string> = {
|
||||||
hotel_admin: 'hotel_admin',
|
hotel_admin: 'hotel_admin',
|
||||||
hotel_manager: 'manager',
|
hotel_manager: 'manager',
|
||||||
receptionist: 'receptionist',
|
receptionist: 'receptionist',
|
||||||
@@ -946,7 +987,7 @@ export function UsersPage() {
|
|||||||
security: 'security',
|
security: 'security',
|
||||||
technician: 'technician',
|
technician: 'technician',
|
||||||
}
|
}
|
||||||
const backendRole = backendRoleMap[u.role] ?? 'manager'
|
const backendRole = backendRoleMap[u.role] ?? u.role
|
||||||
try {
|
try {
|
||||||
if (!u.id) {
|
if (!u.id) {
|
||||||
const created = await api.users.create(slug, {
|
const created = await api.users.create(slug, {
|
||||||
@@ -1107,8 +1148,10 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', meta.color)}>
|
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full',
|
||||||
{meta.label}
|
meta ? meta.color : 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
|
||||||
|
)}>
|
||||||
|
{meta ? meta.label : (savedPermissions.find(r => r.roleKey === u.role)?.name ?? u.role)}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{u.position}</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{u.position}</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user