1147 lines
41 KiB
Vue
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> |