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
// Role creation permissions
const isCustomRole = (r: string) => r.startsWith('custom_')
const hotelAdminAllowedRoles = ['manager', '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: 'Недостаточно прав для создания этой роли' })
}
if (request.user.role === 'manager' && !managerAllowedRoles.includes(role)) {
if (request.user.role === 'manager' && !managerAllowedRoles.includes(role) && !isCustomRole(role)) {
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
}
@@ -131,10 +132,11 @@ const users: FastifyPluginAsync = async (fastify) => {
return reply.code(403).send({ error: 'Нельзя назначить роль системного администратора' })
}
// Managers can change role but not to manager/super_admin
const isCustomRole = (r: string) => r.startsWith('custom_')
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))) {
(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) || isCustomRole(request.body.role)))) {
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
}
}

View File

@@ -242,12 +242,13 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
// ── Helpers ────────────────────────────────────────────────────────────────────
function mapRole(r: string): StaffRole {
if (r === 'housekeeper') return 'housekeeper'
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'
if (r === 'housekeeper') return 'housekeeper'
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'
if (r.startsWith('custom_')) return r as StaffRole
return 'hotel_manager'
}
@@ -281,6 +282,8 @@ function UserModal({
onClose: () => void
onSave: (u: StaffUser, password?: string) => void
}) {
const { savedPermissions } = useRolePermissions()
const customRoles = savedPermissions.filter(r => !r.isSystem)
const [form, setForm] = useState({
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
@@ -320,7 +323,7 @@ function UserModal({
}, password || undefined)
}
const suggestions = DEFAULT_POSITIONS[form.role]
const suggestions = DEFAULT_POSITIONS[form.role as keyof typeof DEFAULT_POSITIONS] ?? []
return (
<Modal
@@ -400,25 +403,47 @@ function UserModal({
<span className="text-xs font-medium text-purple-700 dark:text-purple-300">Сис. администратор роль нельзя изменить</span>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
.filter(([id]) => id !== 'hotel_admin')
.map(([id, meta]) => (
<button
key={id}
type="button"
onClick={() => { set('role', id); 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 === id
? '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}
</button>
))}
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][])
.filter(([id]) => id !== 'hotel_admin')
.map(([id, meta]) => (
<button
key={id}
type="button"
onClick={() => { set('role', id); 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 === id
? '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}
</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>
@@ -598,7 +623,7 @@ function RolesTab() {
}
// ── Create / Delete ────────────────────────────────────────────────────────
const createRole = () => {
const createRole = async () => {
const name = newRoleName.trim()
if (!name) return
const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
@@ -614,6 +639,21 @@ function RolesTab() {
setSelectedRoleId(newRole.id)
setNewRoleName('')
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) => {
@@ -907,6 +947,7 @@ type PageTab = typeof PAGE_TABS[number]['id']
export function UsersPage() {
const { user: currentUser } = useAuth()
const { savedPermissions } = useRolePermissions()
const slug = currentUser?.hotelSlug ?? ''
const [tab, setTab] = useState<PageTab>('staff')
const [users, setUsers] = useState<StaffUser[]>([])
@@ -937,7 +978,7 @@ export function UsersPage() {
const handleSave = async (u: StaffUser, password?: string) => {
const fullName = `${u.firstName} ${u.lastName}`.trim()
const backendRoleMap: Record<StaffRole, string> = {
const backendRoleMap: Record<string, string> = {
hotel_admin: 'hotel_admin',
hotel_manager: 'manager',
receptionist: 'receptionist',
@@ -946,7 +987,7 @@ export function UsersPage() {
security: 'security',
technician: 'technician',
}
const backendRole = backendRoleMap[u.role] ?? 'manager'
const backendRole = backendRoleMap[u.role] ?? u.role
try {
if (!u.id) {
const created = await api.users.create(slug, {
@@ -1107,8 +1148,10 @@ export function UsersPage() {
</div>
</td>
<td className="px-4 py-3">
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', meta.color)}>
{meta.label}
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full',
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>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{u.position}</p>
</td>