- Updated README.md to reflect the new project name and provide an overview of the Role-Based Access Control (RBAC) system. - Added new components for RBAC management, including: - PermissionExample.vue: Demonstrates permission-based navigation. - GroupCard.vue: Displays group information and assigned roles. - PermissionMatrix.vue: Visual representation of permissions across roles and resources. - RoleTemplates.vue: Quick role templates for applying pre-configured permissions. - StatsCards.vue: Displays statistics related to users, groups, and roles. - Introduced useRbacPermissions.js for managing permission checks. - Created docker-compose.yml for PostgreSQL and Redis services. - Developed comprehensive documentation for application management and Authentik integration. - Added multiple pages for managing applications, groups, roles, and users, including bulk operations and templates. - Updated navigation structure to include new RBAC management paths.
629 lines
19 KiB
Vue
629 lines
19 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
title: "Bulk Operations",
|
|
middleware: ["auth"],
|
|
requiresAuth: true,
|
|
breadcrumb: [
|
|
{ name: "Dashboard", path: "/dashboard" },
|
|
{ name: "Users", path: "/users" },
|
|
{ name: "Bulk Operations", path: "/users/bulk", type: "current" }
|
|
]
|
|
});
|
|
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
|
|
// Active operation type
|
|
const activeOperation = ref('users')
|
|
|
|
// File upload states
|
|
const uploadedFiles = ref([])
|
|
const isProcessing = ref(false)
|
|
const csvFile = ref(null)
|
|
const isUploading = ref(false)
|
|
const uploadProgress = ref(0)
|
|
|
|
// Preview data
|
|
const previewData = ref([])
|
|
const headers = ref([])
|
|
|
|
// Bulk operation results
|
|
const operationResults = ref(null)
|
|
|
|
// Form states for different operations
|
|
const bulkUserForm = reactive({
|
|
operation: 'create', // create, update, delete, activate, deactivate
|
|
assignGroups: [],
|
|
assignRoles: [],
|
|
department: '',
|
|
sendInvitations: true,
|
|
syncToAuthentik: true
|
|
})
|
|
|
|
const bulkGroupForm = reactive({
|
|
operation: 'create', // create, update, delete
|
|
parentGroup: '',
|
|
defaultRoles: [],
|
|
syncToAuthentik: true
|
|
})
|
|
|
|
const bulkRoleForm = reactive({
|
|
operation: 'create', // create, update, delete
|
|
permissions: [],
|
|
syncToAuthentik: true
|
|
})
|
|
|
|
// Validation results
|
|
const validationResults = reactive({
|
|
errors: [],
|
|
warnings: []
|
|
})
|
|
|
|
// Progress tracking
|
|
const progress = reactive({
|
|
current: 0,
|
|
total: 0,
|
|
status: 'Ready'
|
|
})
|
|
|
|
// Operation settings
|
|
const operationSettings = reactive({
|
|
operation: 'create',
|
|
batchSize: 100,
|
|
skipErrors: false
|
|
})
|
|
|
|
// Default settings for users
|
|
const defaultSettings = reactive({
|
|
defaultGroups: [],
|
|
defaultRoles: [],
|
|
isActive: true,
|
|
mustChangePassword: true
|
|
})
|
|
|
|
// Authentik sync settings
|
|
const syncSettings = reactive({
|
|
syncToAuthentik: true,
|
|
createGroups: false,
|
|
sendInvitations: true
|
|
})
|
|
|
|
// Available options
|
|
const availableGroups = ref([
|
|
{ id: '1', name: 'IT Department', authentikUUID: 'uuid-1' },
|
|
{ id: '2', name: 'HR Department', authentikUUID: 'uuid-2' },
|
|
{ id: '3', name: 'Finance Department', authentikUUID: 'uuid-3' },
|
|
{ id: '4', name: 'Management', authentikUUID: 'uuid-4' }
|
|
])
|
|
|
|
const availableRoles = ref([
|
|
{ id: '1', name: 'Administrator' },
|
|
{ id: '2', name: 'Manager' },
|
|
{ id: '3', name: 'Editor' },
|
|
{ id: '4', name: 'Viewer' }
|
|
])
|
|
|
|
// Computed options for FormKit selects
|
|
const groupOptions = computed(() =>
|
|
availableGroups.value.map(group => ({
|
|
label: group.name,
|
|
value: group.id
|
|
}))
|
|
)
|
|
|
|
const roleOptions = computed(() =>
|
|
availableRoles.value.map(role => ({
|
|
label: role.name,
|
|
value: role.id
|
|
}))
|
|
)
|
|
|
|
// CSV Templates
|
|
const csvTemplates = computed(() => {
|
|
switch (activeOperation.value) {
|
|
case 'users':
|
|
return {
|
|
headers: ['username', 'email', 'firstName', 'lastName', 'department', 'jobTitle', 'phone', 'groups', 'roles'],
|
|
sample: [
|
|
['john.doe', 'john@company.com', 'John', 'Doe', 'IT', 'Software Engineer', '+1234567890', 'IT Department', 'Editor'],
|
|
['jane.smith', 'jane@company.com', 'Jane', 'Smith', 'HR', 'HR Manager', '+1234567891', 'HR Department', 'Manager']
|
|
]
|
|
}
|
|
case 'groups':
|
|
return {
|
|
headers: ['name', 'description', 'parentGroup', 'defaultRoles'],
|
|
sample: [
|
|
['Development Team', 'Software development team', 'IT Department', 'Editor,Viewer'],
|
|
['QA Team', 'Quality assurance team', 'IT Department', 'Viewer']
|
|
]
|
|
}
|
|
case 'roles':
|
|
return {
|
|
headers: ['name', 'description', 'permissions'],
|
|
sample: [
|
|
['Senior Developer', 'Senior development role', 'menu.dashboard,component.user.edit_button'],
|
|
['Junior Developer', 'Junior development role', 'menu.dashboard,component.user.view']
|
|
]
|
|
}
|
|
default:
|
|
return { headers: [], sample: [] }
|
|
}
|
|
})
|
|
|
|
// Methods
|
|
const downloadTemplate = () => {
|
|
const template = csvTemplates.value
|
|
const csvContent = [
|
|
template.headers.join(','),
|
|
...template.sample.map(row => row.join(','))
|
|
].join('\n')
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv' })
|
|
const url = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `${activeOperation.value}_template.csv`
|
|
link.click()
|
|
window.URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const handleFileUpload = async (event) => {
|
|
const file = event.target.files[0]
|
|
if (!file) return
|
|
|
|
csvFile.value = file
|
|
isUploading.value = true
|
|
uploadProgress.value = 0
|
|
|
|
try {
|
|
// Simulate upload progress
|
|
const progressInterval = setInterval(() => {
|
|
uploadProgress.value += 10
|
|
if (uploadProgress.value >= 100) {
|
|
clearInterval(progressInterval)
|
|
}
|
|
}, 100)
|
|
|
|
// Parse CSV file
|
|
const text = await file.text()
|
|
const lines = text.split('\n').filter(line => line.trim())
|
|
const headerRow = lines[0].split(',').map(h => h.trim())
|
|
headers.value = headerRow
|
|
|
|
const data = lines.slice(1).map((line, index) => {
|
|
const values = line.split(',').map(v => v.trim())
|
|
const row = headerRow.reduce((obj, header, headerIndex) => {
|
|
obj[header] = values[headerIndex] || ''
|
|
return obj
|
|
}, {})
|
|
|
|
// Add validation status
|
|
row._validation = validateRow(row, index + 2) // +2 because we start from line 2 (after header)
|
|
return row
|
|
})
|
|
|
|
previewData.value = data
|
|
validateAllData()
|
|
|
|
} catch (error) {
|
|
console.error('File upload failed:', error)
|
|
} finally {
|
|
isUploading.value = false
|
|
}
|
|
}
|
|
|
|
const validateRow = (row, lineNumber) => {
|
|
const errors = []
|
|
|
|
// Basic validation
|
|
if (!row.username) errors.push('Username is required')
|
|
if (!row.email) errors.push('Email is required')
|
|
if (!row.firstName) errors.push('First name is required')
|
|
if (!row.lastName) errors.push('Last name is required')
|
|
|
|
// Email format validation
|
|
if (row.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email)) {
|
|
errors.push('Invalid email format')
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
lineNumber
|
|
}
|
|
}
|
|
|
|
const validateAllData = () => {
|
|
validationResults.errors = []
|
|
validationResults.warnings = []
|
|
|
|
previewData.value.forEach((row) => {
|
|
if (!row._validation.valid) {
|
|
row._validation.errors.forEach(error => {
|
|
validationResults.errors.push({
|
|
row: row._validation.lineNumber,
|
|
message: error
|
|
})
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
const processBulkOperation = async () => {
|
|
if (validationResults.errors.length > 0 && !operationSettings.skipErrors) {
|
|
return
|
|
}
|
|
|
|
isProcessing.value = true
|
|
progress.total = previewData.value.length
|
|
progress.current = 0
|
|
progress.status = 'Processing users...'
|
|
|
|
try {
|
|
let endpoint = ''
|
|
let requestData = {}
|
|
|
|
switch (activeOperation.value) {
|
|
case 'users':
|
|
endpoint = '/api/users/bulk'
|
|
requestData = {
|
|
operation: bulkUserForm.operation,
|
|
users: previewData.value,
|
|
options: {
|
|
assignGroups: bulkUserForm.assignGroups,
|
|
assignRoles: bulkUserForm.assignRoles,
|
|
department: bulkUserForm.department,
|
|
sendInvitations: bulkUserForm.sendInvitations,
|
|
syncToAuthentik: bulkUserForm.syncToAuthentik
|
|
}
|
|
}
|
|
break
|
|
case 'groups':
|
|
endpoint = '/api/groups/bulk'
|
|
requestData = {
|
|
operation: bulkGroupForm.operation,
|
|
groups: previewData.value,
|
|
options: {
|
|
parentGroup: bulkGroupForm.parentGroup,
|
|
defaultRoles: bulkGroupForm.defaultRoles,
|
|
syncToAuthentik: bulkGroupForm.syncToAuthentik
|
|
}
|
|
}
|
|
break
|
|
case 'roles':
|
|
endpoint = '/api/roles/bulk'
|
|
requestData = {
|
|
operation: bulkRoleForm.operation,
|
|
roles: previewData.value,
|
|
options: {
|
|
permissions: bulkRoleForm.permissions,
|
|
syncToAuthentik: bulkRoleForm.syncToAuthentik
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
const response = await $fetch(endpoint, {
|
|
method: 'POST',
|
|
body: requestData
|
|
})
|
|
|
|
operationResults.value = response
|
|
|
|
progress.status = 'Bulk operation completed successfully'
|
|
|
|
// Redirect to users page after success
|
|
setTimeout(() => {
|
|
navigateTo('/users')
|
|
}, 2000)
|
|
|
|
} catch (error) {
|
|
console.error('Bulk operation failed:', error)
|
|
progress.status = 'Bulk operation failed'
|
|
} finally {
|
|
isProcessing.value = false
|
|
}
|
|
}
|
|
|
|
const clearData = () => {
|
|
uploadedFiles.value = []
|
|
previewData.value = []
|
|
headers.value = []
|
|
validationResults.errors = []
|
|
validationResults.warnings = []
|
|
progress.current = 0
|
|
progress.total = 0
|
|
progress.status = 'Ready'
|
|
}
|
|
|
|
const resetOperation = () => {
|
|
clearData()
|
|
}
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
// Load available groups and roles
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<LayoutsBreadcrumb />
|
|
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Bulk Operations</h1>
|
|
<p class="text-gray-600 dark:text-gray-400">Import/export users and manage bulk operations</p>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<rs-button @click="downloadTemplate" variant="primary-outline">
|
|
<Icon name="ph:download" class="w-4 h-4 mr-2" />
|
|
Download Template
|
|
</rs-button>
|
|
<rs-button @click="exportUsers" variant="primary-outline">
|
|
<Icon name="ph:export" class="w-4 h-4 mr-2" />
|
|
Export Users
|
|
</rs-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<!-- Main Upload Area -->
|
|
<div class="lg:col-span-2 space-y-6">
|
|
|
|
<!-- CSV Upload -->
|
|
<rs-card>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Upload CSV File</h3>
|
|
</template>
|
|
<template #body>
|
|
<FormKit type="form" @submit="processUpload" :actions="false">
|
|
<FormKit
|
|
v-model="uploadedFiles"
|
|
type="dropzone"
|
|
label="Choose CSV File"
|
|
accept=".csv,text/csv"
|
|
:multiple="false"
|
|
maxSize="5MB"
|
|
help="Upload a CSV file with user data. Maximum file size: 5MB"
|
|
validation="required"
|
|
validation-visibility="dirty"
|
|
/>
|
|
|
|
<div class="mt-4">
|
|
<rs-button type="submit" :disabled="!uploadedFiles || isProcessing">
|
|
<Icon name="ph:upload" class="w-4 h-4 mr-2" />
|
|
{{ isProcessing ? 'Processing...' : 'Upload & Preview' }}
|
|
</rs-button>
|
|
</div>
|
|
</FormKit>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Data Preview -->
|
|
<rs-card v-if="previewData.length > 0">
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Data Preview</h3>
|
|
<rs-badge variant="info">{{ previewData.length }} rows</rs-badge>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<div class="overflow-x-auto max-h-96">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
<tr>
|
|
<th v-for="header in headers" :key="header" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
{{ header }}
|
|
</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
<tr v-for="(row, index) in previewData.slice(0, 10)" :key="index">
|
|
<td v-for="header in headers" :key="header" class="px-4 py-2 text-sm text-gray-900 dark:text-white">
|
|
{{ row[header] }}
|
|
</td>
|
|
<td class="px-4 py-2">
|
|
<rs-badge :variant="row._validation?.valid ? 'success' : 'danger'" class="text-xs">
|
|
{{ row._validation?.valid ? 'Valid' : 'Error' }}
|
|
</rs-badge>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div v-if="previewData.length > 10" class="p-4 text-center text-sm text-gray-500">
|
|
Showing 10 of {{ previewData.length }} rows
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Validation Results -->
|
|
<rs-card v-if="validationResults.errors.length > 0">
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-red-600">Validation Errors</h3>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
|
<div v-for="error in validationResults.errors" :key="error.row" class="flex items-start space-x-2">
|
|
<rs-badge variant="danger" class="text-xs">Row {{ error.row }}</rs-badge>
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ error.message }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
</div>
|
|
|
|
<!-- Settings Sidebar -->
|
|
<div class="space-y-6">
|
|
|
|
<!-- Operation Settings -->
|
|
<rs-card>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Operation Settings</h3>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<FormKit
|
|
v-model="operationSettings.operation"
|
|
type="radio"
|
|
label="Operation Type"
|
|
:options="[
|
|
{ label: 'Create New Users', value: 'create' },
|
|
{ label: 'Update Existing Users', value: 'update' },
|
|
{ label: 'Create or Update', value: 'upsert' }
|
|
]"
|
|
validation="required"
|
|
:classes="{
|
|
fieldset: 'border-0 !p-0',
|
|
legend: '!font-semibold !text-sm mb-0',
|
|
options: '!flex !flex-col gap-2 mt-3',
|
|
}"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="operationSettings.batchSize"
|
|
type="number"
|
|
label="Batch Size"
|
|
help="Number of users to process at once"
|
|
:value="100"
|
|
validation="required|number|min:1|max:1000"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="operationSettings.skipErrors"
|
|
type="checkbox"
|
|
label="Skip Validation Errors"
|
|
help="Continue processing even if some rows have errors"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- User Defaults -->
|
|
<rs-card>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Default Settings</h3>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<FormKit
|
|
v-model="defaultSettings.defaultGroups"
|
|
type="select"
|
|
label="Default Groups"
|
|
:options="groupOptions"
|
|
multiple
|
|
help="Groups to assign to all imported users"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="defaultSettings.defaultRoles"
|
|
type="select"
|
|
label="Default Roles"
|
|
:options="roleOptions"
|
|
multiple
|
|
help="Roles to assign to all imported users"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="defaultSettings.isActive"
|
|
type="checkbox"
|
|
label="Active by Default"
|
|
help="Set all imported users as active"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="defaultSettings.mustChangePassword"
|
|
type="checkbox"
|
|
label="Require Password Change"
|
|
help="Force password change on first login"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Authentik Sync -->
|
|
<rs-card>
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentik Sync</h3>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<FormKit
|
|
v-model="syncSettings.syncToAuthentik"
|
|
type="checkbox"
|
|
label="Sync to Authentik"
|
|
help="Create/update users in Authentik SSO system"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="syncSettings.createGroups"
|
|
type="checkbox"
|
|
label="Create Missing Groups"
|
|
help="Automatically create groups that don't exist in Authentik"
|
|
:disabled="!syncSettings.syncToAuthentik"
|
|
/>
|
|
|
|
<FormKit
|
|
v-model="syncSettings.sendInvitations"
|
|
type="checkbox"
|
|
label="Send Email Invitations"
|
|
help="Send welcome emails to new users"
|
|
:disabled="!syncSettings.syncToAuthentik"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Progress -->
|
|
<rs-card v-if="isProcessing">
|
|
<template #header>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Processing Progress</h3>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<span>{{ progress.current }} / {{ progress.total }}</span>
|
|
<span>{{ Math.round((progress.current / progress.total) * 100) }}%</span>
|
|
</div>
|
|
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
class="bg-primary h-2 rounded-full transition-all duration-300"
|
|
:style="{ width: (progress.current / progress.total * 100) + '%' }"
|
|
></div>
|
|
</div>
|
|
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ progress.status }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div v-if="previewData.length > 0 && !isProcessing" class="mt-6 flex justify-end space-x-3">
|
|
<rs-button @click="clearData" variant="primary-outline">
|
|
<Icon name="ph:trash" class="w-4 h-4 mr-2" />
|
|
Clear Data
|
|
</rs-button>
|
|
<rs-button
|
|
@click="processBulkOperation"
|
|
:disabled="validationResults.errors.length > 0 && !operationSettings.skipErrors"
|
|
>
|
|
<Icon name="ph:users" class="w-4 h-4 mr-2" />
|
|
Process {{ previewData.length }} Users
|
|
</rs-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Custom styles for bulk operations */
|
|
</style> |