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
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user