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:
2026-04-20 19:57:59 +03:00
parent a701366216
commit 9e1eff30a6
3 changed files with 82 additions and 35 deletions

View 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;

View File

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

View File

@@ -248,6 +248,7 @@ function mapRole(r: string): StaffRole {
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,6 +403,7 @@ 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="space-y-2">
<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]][]) {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
.filter(([id]) => id !== 'hotel_admin') .filter(([id]) => id !== 'hotel_admin')
@@ -420,6 +424,27 @@ function UserModal({
</button> </button>
))} ))}
</div> </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>
@@ -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>