2025-05-29 20:17:32 +08:00

1147 lines
41 KiB
Vue

<script setup>
definePageMeta({
title: "Assign RBAC Permissions",
middleware: ["auth"],
requiresAuth: true,
});
import { ref, reactive, onMounted, watch } from 'vue'
// Form data
const formData = reactive({
roleId: '',
permissions: [],
users: [] // Add users array to store assigned users
})
// New role form data
const newRoleForm = reactive({
name: '',
description: ''
})
// Modal state
const isNewRoleModalOpen = ref(false)
// Loading states
const isLoading = ref(false)
const isSubmitting = ref(false)
const isCreatingRole = ref(false)
// Options data
const roles = ref([])
const menus = ref([])
const components = ref([])
const users = ref([]) // Store available users
const searchQuery = ref('') // For user search
const filteredUsers = ref([]) // For filtered user list
// Action options (static)
const actionOptions = [
{ value: 'View', label: 'View', icon: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z' },
{ value: 'Create', label: 'Create', icon: 'M12 6v6m0 0v6m0-6h6m-6 0H6' },
{ value: 'Edit', label: 'Edit', icon: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z' },
{ value: 'Delete', label: 'Delete', icon: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' }
]
// Component name validation
const validateComponentName = (value) => {
if (value === '*') {
return true
}
// Only allow letters, numbers, underscores, and hyphens
const pattern = /^[a-zA-Z0-9_-]+$/
return pattern.test(value)
}
const formatComponentName = (value) => {
// Allow asterisk as a special case
if (value === '*') {
return value
}
// Remove spaces and special characters except underscore and hyphen
return value.replace(/[^a-zA-Z0-9_-]/g, '')
}
const getComponentDisplayName = (componentName) => {
return componentName === '*' ? 'All Components' : componentName
}
// Form validation
const errors = reactive({})
const roleErrors = reactive({
name: '',
description: ''
})
// Mock API functions (replace with actual API calls)
const fetchRoles = async () => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: '1', name: 'Administrator', description: 'Full system access' },
{ id: '2', name: 'Manager', description: 'Management level access' },
{ id: '3', name: 'Editor', description: 'Content editing access' },
{ id: '4', name: 'Viewer', description: 'Read-only access' }
])
}, 500)
})
}
const fetchMenus = async () => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: '1', name: 'All Menus', path: '/*' },
{ id: '2', name: 'Dashboard', path: '/dashboard' },
{ id: '3', name: 'Users', path: '/users' },
{ id: '4', name: 'Settings', path: '/settings' },
{ id: '5', name: 'Reports', path: '/reports' },
{ id: '6', name: 'Analytics', path: '/analytics' }
])
}, 300)
})
}
const fetchComponents = async () => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: '1', name: 'UserList', description: 'User listing component' },
{ id: '2', name: 'UserForm', description: 'User creation/editing form' },
{ id: '3', name: 'SettingsPanel', description: 'System settings panel' },
{ id: '4', name: 'ReportGenerator', description: 'Report generation tool' },
{ id: '5', name: 'Dashboard', description: 'Main dashboard component' }
])
}, 400)
})
}
const fetchUsers = async () => {
// Simulate API call - replace with actual API call
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: '1', name: 'John Doe', email: 'john@example.com', department: 'IT' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', department: 'HR' },
{ id: '3', name: 'Mike Johnson', email: 'mike@example.com', department: 'Finance' },
{ id: '4', name: 'Sarah Williams', email: 'sarah@example.com', department: 'Marketing' }
])
}, 500)
})
}
const createNewRole = async (roleData) => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
const newRole = {
id: String(roles.value.length + 1),
name: roleData.name,
description: roleData.description
}
resolve(newRole)
}, 500)
})
}
// Load initial data
onMounted(async () => {
isLoading.value = true
try {
const [rolesData, menusData, componentsData, usersData] = await Promise.all([
fetchRoles(),
fetchMenus(),
fetchComponents(),
fetchUsers()
])
roles.value = rolesData
menus.value = menusData
components.value = componentsData
users.value = usersData
filteredUsers.value = usersData
} catch (error) {
console.error('Error loading data:', error)
} finally {
isLoading.value = false
}
})
// Add new permission entry
const addPermission = () => {
formData.permissions.push({
id: Date.now(), // Temporary ID for tracking
menuName: '',
componentName: '',
actions: [] // Array of selected actions
})
}
// Remove permission entry
const removePermission = (index) => {
formData.permissions.splice(index, 1)
}
// Clear specific error
const clearError = (field) => {
delete errors[field]
}
// Validate form
const validateForm = () => {
const newErrors = {}
if (!formData.roleId) {
newErrors.roleId = 'Please select a role'
}
if (formData.permissions.length === 0) {
newErrors.permissions = 'At least one permission must be assigned'
} else {
formData.permissions.forEach((permission, index) => {
if (!permission.menuName) {
newErrors[`permission_${index}_menu`] = 'Menu is required'
}
if (!permission.componentName) {
newErrors[`permission_${index}_component`] = 'Component is required'
} else if (!validateComponentName(permission.componentName)) {
newErrors[`permission_${index}_component`] = 'Component name can only contain letters, numbers, underscores (_) and hyphens (-)'
}
if (!permission.actions || permission.actions.length === 0) {
newErrors[`permission_${index}_actions`] = 'At least one action must be selected'
}
})
}
Object.assign(errors, newErrors)
return Object.keys(newErrors).length === 0
}
// Handle component name input
const handleComponentNameInput = (permission, index) => {
// Format the input value
permission.componentName = formatComponentName(permission.componentName)
// Clear error if value is valid
if (validateComponentName(permission.componentName)) {
clearError(`permission_${index}_component`)
}
}
// Submit form
const submitForm = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
// Prepare submission data
const submissionData = {
roleId: formData.roleId,
permissions: formData.permissions.map(p => ({
menuName: p.menuName,
componentName: p.componentName,
action: p.action
})),
users: formData.users.map(u => u.id)
}
console.log('Submitting RBAC assignment:', submissionData)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
alert('RBAC permissions assigned successfully!')
resetForm()
} catch (error) {
console.error('Error assigning permissions:', error)
alert('Error assigning permissions. Please try again.')
} finally {
isSubmitting.value = false
}
}
// Reset form
const resetForm = () => {
formData.roleId = ''
formData.permissions = []
Object.keys(errors).forEach(key => delete errors[key])
}
// Get role name by ID
const getRoleName = (roleId) => {
const role = roles.value.find(r => r.id === roleId)
return role ? role.name : ''
}
// Get menu name by ID
const getMenuName = (menuId) => {
const menu = menus.value.find(m => m.id === menuId)
return menu ? menu.name : ''
}
// Get component name by ID
const getComponentName = (componentId) => {
const component = components.value.find(c => c.id === componentId)
return component ? component.name : ''
}
// Initialize with one permission entry
watch(() => formData.permissions.length, (newLength) => {
if (newLength === 0) {
addPermission()
}
}, { immediate: true })
// User management functions
const getAvailableUsers = () => {
return users.value.filter(user =>
!formData.users.some(assignedUser => assignedUser.id === user.id)
)
}
const filterUsers = () => {
const query = searchQuery.value.toLowerCase()
const availableUsers = getAvailableUsers()
filteredUsers.value = availableUsers.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.department.toLowerCase().includes(query)
)
}
const addUser = (user) => {
if (!formData.users.some(u => u.id === user.id)) {
formData.users.push(user)
filterUsers() // Refresh the available users list
}
}
const removeUser = (userId) => {
const index = formData.users.findIndex(u => u.id === userId)
if (index !== -1) {
formData.users.splice(index, 1)
filterUsers() // Refresh the available users list
}
}
// Watch for search query changes
watch(searchQuery, () => {
filterUsers()
})
// Watch for changes in assigned users to update available users list
watch(() => formData.users.length, () => {
filterUsers()
})
// Role management functions
const openNewRoleModal = () => {
isNewRoleModalOpen.value = true
newRoleForm.name = ''
newRoleForm.description = ''
roleErrors.name = ''
roleErrors.description = ''
}
const closeNewRoleModal = () => {
isNewRoleModalOpen.value = false
}
const validateNewRoleForm = () => {
let isValid = true
roleErrors.name = ''
roleErrors.description = ''
if (!newRoleForm.name.trim()) {
roleErrors.name = 'Role name is required'
isValid = false
} else if (roles.value.some(role => role.name.toLowerCase() === newRoleForm.name.toLowerCase())) {
roleErrors.name = 'Role name already exists'
isValid = false
}
if (!newRoleForm.description.trim()) {
roleErrors.description = 'Role description is required'
isValid = false
}
return isValid
}
const submitNewRole = async () => {
if (!validateNewRoleForm()) {
return
}
isCreatingRole.value = true
try {
const newRole = await createNewRole(newRoleForm)
roles.value.push(newRole)
formData.roleId = newRole.id // Automatically select the new role
closeNewRoleModal()
} catch (error) {
console.error('Error creating role:', error)
} finally {
isCreatingRole.value = false
}
}
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<template #header>
<div class="flex justify-between items-center">
<div>
<h2 class="text-xl font-semibold text-gray-900">Assign RBAC Permissions</h2>
<p class="text-sm text-gray-600 mt-1">Assign permissions to roles based on menus and components</p>
</div>
</div>
</template>
<template #body>
<div v-if="isLoading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-2 text-gray-600">Loading data...</span>
</div>
<form v-else @submit.prevent="submitForm" class="space-y-8">
<!-- Role Selection -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">Group Selection</h3>
<div class="max-w-md">
<label for="roleId" class="block text-sm font-medium text-gray-700 mb-2">
Select Group <span class="text-red-500">*</span>
</label>
<div class="flex space-x-2">
<div class="select-container flex-1">
<select
id="roleId"
v-model="formData.roleId"
@change="clearError('roleId')"
class="w-full px-3 py-2.5 border border-gray-300 rounded-md bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-500': errors.roleId }"
>
<option value="" disabled>Choose a group</option>
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }} - {{ role.description }}
</option>
</select>
</div>
<button
type="button"
@click="openNewRoleModal"
class="px-4 py-2.5 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
>
<span class="flex items-center">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
New Role
</span>
</button>
</div>
<p v-if="errors.roleId" class="text-red-500 text-sm mt-1">{{ errors.roleId }}</p>
</div>
</div>
<!-- New Role Modal -->
<div
v-if="isNewRoleModalOpen"
class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"
@click.self="closeNewRoleModal"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Create New Role</h3>
<button
type="button"
@click="closeNewRoleModal"
class="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form @submit.prevent="submitNewRole" class="space-y-4">
<div>
<label for="roleName" class="block text-sm font-medium text-gray-700 mb-1">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="roleName"
v-model="newRoleForm.name"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-500': roleErrors.name }"
placeholder="Enter role name"
/>
<p v-if="roleErrors.name" class="text-red-500 text-sm mt-1">{{ roleErrors.name }}</p>
</div>
<div>
<label for="roleDescription" class="block text-sm font-medium text-gray-700 mb-1">
Description <span class="text-red-500">*</span>
</label>
<textarea
id="roleDescription"
v-model="newRoleForm.description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-500': roleErrors.description }"
placeholder="Enter role description"
></textarea>
<p v-if="roleErrors.description" class="text-red-500 text-sm mt-1">{{ roleErrors.description }}</p>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
type="button"
@click="closeNewRoleModal"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
:disabled="isCreatingRole"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
:disabled="isCreatingRole"
>
<span v-if="isCreatingRole">Creating...</span>
<span v-else>Create Role</span>
</button>
</div>
</form>
</div>
</div>
<!-- Permissions Section -->
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h3 class="text-lg font-medium text-gray-900">Permissions</h3>
<p class="text-sm text-gray-600">Select permissions by menu and component</p>
</div>
<button
type="button"
@click="addPermission"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Add Permission
</button>
</div>
<p v-if="errors.permissions" class="text-red-500 text-sm mb-4">{{ errors.permissions }}</p>
<!-- Permission Entries -->
<div class="space-y-4">
<div
v-for="(permission, index) in formData.permissions"
:key="permission.id"
class="border border-gray-200 rounded-lg p-4 bg-gray-50"
>
<div class="flex justify-between items-start mb-4">
<h4 class="text-md font-medium text-gray-900">Permission #{{ index + 1 }}</h4>
<button
v-if="formData.permissions.length > 1"
type="button"
@click="removePermission(index)"
class="text-red-600 hover:text-red-800 focus:outline-none"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Menu Selection -->
<div>
<label :for="`menu_${index}`" class="block text-sm font-medium text-gray-700 mb-2">
Menu <span class="text-red-500">*</span>
</label>
<div class="select-container">
<select
:id="`menu_${index}`"
v-model="permission.menuName"
@change="clearError(`permission_${index}_menu`)"
class="w-full px-3 py-2.5 border border-gray-300 rounded-md bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-500': errors[`permission_${index}_menu`] }"
>
<option value="" disabled>Select menu</option>
<option v-for="menu in menus" :key="menu.id" :value="menu.id">
{{ menu.name }}
</option>
</select>
</div>
<p v-if="errors[`permission_${index}_menu`]" class="text-red-500 text-xs mt-1">
{{ errors[`permission_${index}_menu`] }}
</p>
</div>
<!-- Component Input -->
<div>
<label :for="`component_${index}`" class="block text-sm font-medium text-gray-700 mb-2">
Component <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
type="text"
:id="`component_${index}`"
v-model="permission.componentName"
@input="handleComponentNameInput(permission, index)"
placeholder="Enter component name"
class="w-full px-3 py-2.5 border border-gray-300 rounded-md bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
:class="{
'border-red-500 bg-red-50': errors[`permission_${index}_component`],
'border-green-500 bg-green-50': permission.componentName && validateComponentName(permission.componentName)
}"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<!-- Success Icon -->
<svg
v-if="permission.componentName && validateComponentName(permission.componentName)"
class="h-5 w-5 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<!-- Error Icon -->
<svg
v-if="errors[`permission_${index}_component`]"
class="h-5 w-5 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<div class="mt-1">
<p v-if="errors[`permission_${index}_component`]" class="text-red-500 text-xs">
{{ errors[`permission_${index}_component`] }}
</p>
<p v-else class="text-gray-500 text-xs">
Use letters, numbers, underscores (_), hyphens (-), or * for all components. No spaces allowed.
</p>
<p v-if="permission.componentName === '*'" class="text-blue-600 text-xs mt-1 flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
This will apply to all components
</p>
</div>
</div>
<!-- Action Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Actions <span class="text-red-500">*</span>
</label>
<div class="flex items-center">
<div class="inline-flex rounded-md shadow-sm" role="group">
<button
v-for="(action, actionIndex) in actionOptions"
:key="action.value"
type="button"
@click="() => {
if (permission.actions.includes(action.value)) {
permission.actions = permission.actions.filter(a => a !== action.value)
} else {
permission.actions.push(action.value)
}
clearError(`permission_${index}_actions`)
}"
:class="[
'relative min-w-[100px] flex items-center justify-center h-[44px] px-4 text-sm font-medium transition-all duration-200',
'focus:z-10 focus:outline-none focus:ring-2 focus:ring-blue-500',
permission.actions.includes(action.value)
? 'bg-blue-50 text-blue-700 hover:bg-blue-100 border-blue-300'
: 'bg-white text-gray-700 hover:bg-gray-50 border-gray-300',
'border',
// Round only the first and last buttons
actionIndex === 0 ? 'rounded-l-md' : '',
actionIndex === actionOptions.length - 1 ? 'rounded-r-md' : '',
// Add negative margin to overlap borders
actionIndex !== 0 ? '-ml-px' : ''
]"
>
<div class="flex items-center justify-center space-x-1.5">
<svg
class="w-4 h-4"
:class="permission.actions.includes(action.value) ? 'text-blue-600' : 'text-gray-500'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" :d="action.icon"/>
</svg>
<span>{{ action.label }}</span>
</div>
<!-- Selected indicator dot -->
<div
v-if="permission.actions.includes(action.value)"
class="absolute top-3 right-2 w-2 h-2 rounded-full bg-blue-500"
></div>
</button>
</div>
<!-- Error or status message -->
<!-- <div class="ml-4 flex-shrink-0">
<p v-if="errors[`permission_${index}_actions`]" class="text-red-500 text-xs flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ errors[`permission_${index}_actions`] }}
</p>
<p v-else-if="permission.actions.length === 0" class="text-gray-500 text-xs">
Select permissions
</p>
<p v-else class="text-blue-600 text-xs flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ permission.actions.length }} selected
</p>
</div> -->
</div>
</div>
</div>
<!-- Permission Preview -->
<div v-if="permission.menuName && permission.componentName && permission.actions.length > 0" class="mt-4 p-3 bg-blue-50 rounded-md">
<p class="text-sm text-blue-800">
<strong>Preview:</strong>
{{ getMenuName(permission.menuName) }} → {{ getComponentDisplayName(permission.componentName) }} →
<span class="space-x-1">
<span
v-for="action in permission.actions"
:key="action"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
<svg
class="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="actionOptions.find(a => a.value === action)?.icon"
/>
</svg>
{{ action }}
</span>
</span>
</p>
</div>
</div>
</div>
</div>
<!-- User Management Section -->
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h3 class="text-lg font-medium text-gray-900">Assign Users</h3>
<p class="text-sm text-gray-600">Add users to this role group</p>
</div>
</div>
<!-- User Search -->
<div class="mb-6">
<div class="max-w-md">
<label for="userSearch" class="block text-sm font-medium text-gray-700 mb-2">
Search Users
</label>
<input
type="text"
id="userSearch"
v-model="searchQuery"
placeholder="Search by name, email, or department"
class="w-full px-3 py-2.5 border border-gray-300 rounded-md bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
/>
</div>
</div>
<!-- Available Users List -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 mb-3">Available Users</h4>
<div class="max-h-60 overflow-y-auto">
<div
v-if="filteredUsers.length > 0"
v-for="user in filteredUsers"
:key="user.id"
class="flex items-center justify-between p-2 hover:bg-gray-50 rounded-md cursor-pointer"
@click="addUser(user)"
>
<div>
<p class="text-sm font-medium text-gray-900">{{ user.name }}</p>
<p class="text-xs text-gray-500">{{ user.email }}</p>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
{{ user.department }}
</span>
</div>
<button
type="button"
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Add
</button>
</div>
<div
v-if="filteredUsers.length === 0 && !searchQuery"
class="text-center py-4 text-gray-500 text-sm"
>
All users have been assigned
</div>
<div
v-if="filteredUsers.length === 0 && searchQuery"
class="text-center py-4 text-gray-500 text-sm"
>
No users found matching your search
</div>
</div>
</div>
<!-- Assigned Users List -->
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 mb-3">Assigned Users</h4>
<div class="max-h-60 overflow-y-auto">
<div
v-for="user in formData.users"
:key="user.id"
class="flex items-center justify-between p-2 hover:bg-gray-50 rounded-md"
>
<div>
<p class="text-sm font-medium text-gray-900">{{ user.name }}</p>
<p class="text-xs text-gray-500">{{ user.email }}</p>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
{{ user.department }}
</span>
</div>
<button
type="button"
@click="removeUser(user.id)"
class="text-red-600 hover:text-red-800 text-sm font-medium"
>
Remove
</button>
</div>
<div
v-if="formData.users.length === 0"
class="text-center py-4 text-gray-500 text-sm"
>
No users assigned yet
</div>
</div>
</div>
</div>
</div>
<!-- Summary Section -->
<div v-if="formData.roleId && (formData.permissions.some(p => p.menuName && p.componentName && p.actions.length > 0) || formData.users.length > 0)"
class="bg-green-50 border border-green-200 rounded-lg p-6">
<h3 class="text-lg font-medium text-green-900 mb-3">Assignment Summary</h3>
<p class="text-sm text-green-800 mb-3">
<strong>Role:</strong> {{ getRoleName(formData.roleId) }}
</p>
<!-- Permissions Summary -->
<div v-if="formData.permissions.some(p => p.menuName && p.componentName && p.actions.length > 0)" class="mb-4">
<p class="text-sm text-green-800 mb-2"><strong>Permissions:</strong></p>
<ul class="list-disc list-inside text-sm text-green-700 space-y-2">
<li v-for="(permission, index) in formData.permissions" :key="index">
<span v-if="permission.menuName && permission.componentName && permission.actions.length > 0">
<div class="ml-4 mt-1">
<span class="font-medium">{{ getMenuName(permission.menuName) }}</span>
<span class="font-medium">{{ getComponentDisplayName(permission.componentName) }}</span>
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="action in permission.actions"
:key="action"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
>
{{ action }}
</span>
</div>
</div>
</span>
</li>
</ul>
</div>
<!-- Users Summary -->
<div v-if="formData.users.length > 0">
<p class="text-sm text-green-800 mb-2"><strong>Assigned Users:</strong></p>
<ul class="list-disc list-inside text-sm text-green-700 space-y-1">
<li v-for="user in formData.users" :key="user.id">
{{ user.name }} ({{ user.department }})
</li>
</ul>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-4 pt-6 border-t border-gray-200">
<button
type="button"
@click="resetForm"
class="px-6 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
:disabled="isSubmitting"
>
Reset
</button>
<button
type="submit"
class="px-6 py-2 bg-blue-600 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSubmitting || formData.permissions.length === 0"
>
<span v-if="isSubmitting">Assigning...</span>
<span v-else>Assign Permissions</span>
</button>
</div>
</form>
</template>
</rs-card>
</div>
</template>
<style scoped>
/* Animation for loading spinner */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Modal animations */
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-slide-in {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.fixed {
animation: modal-fade-in 0.2s ease-out;
}
.fixed > div {
animation: modal-slide-in 0.2s ease-out;
}
/* Grid responsive styles */
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@media (min-width: 768px) {
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.gap-4 {
gap: 1rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
.space-y-8 > * + * {
margin-top: 2rem;
}
/* Enhanced input styles */
input[type="text"] {
appearance: none;
transition: all 0.2s ease-in-out;
}
input[type="text"]:hover {
border-color: #4B5563;
}
input[type="text"]:focus {
border-color: #3B82F6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
input[type="text"]::placeholder {
color: #9CA3AF;
}
input[type="text"]:disabled {
background-color: #F3F4F6;
cursor: not-allowed;
opacity: 0.7;
}
/* Enhanced dropdown styles */
select {
appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
transition: all 0.2s ease-in-out;
}
select:hover {
border-color: #4B5563;
}
select:focus {
border-color: #3B82F6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
outline: none;
ring: 2px;
ring-color: #3b82f6;
}
select:disabled {
background-color: #F3F4F6;
cursor: not-allowed;
opacity: 0.7;
}
select option {
padding: 0.5rem;
font-size: 0.875rem;
}
select option:hover {
background-color: #F3F4F6;
}
/* Focus and error styles */
.border-red-500 {
border-color: #ef4444;
}
.text-red-500 {
color: #ef4444;
}
/* Button hover effects */
button:hover {
transition: all 0.2s ease-in-out;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Card and section styling */
.bg-gray-50 {
background-color: #f9fafb;
}
.bg-blue-50 {
background-color: #eff6ff;
}
.bg-green-50 {
background-color: #f0fdf4;
}
.border-gray-200 {
border-color: #e5e7eb;
}
.border-green-200 {
border-color: #bbf7d0;
}
.rounded-lg {
border-radius: 0.5rem;
}
.rounded-md {
border-radius: 0.375rem;
}
/* Custom dropdown container */
.select-container {
position: relative;
width: 100%;
}
.select-container::after {
pointer-events: none;
}
/* Error state enhancements */
select.border-red-500,
input.border-red-500 {
border-color: #ef4444;
background-color: #FEF2F2;
}
select.border-red-500:focus,
input.border-red-500:focus {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
/* Placeholder styling */
select option[value=""] {
color: #9CA3AF;
}
</style>