Afiq f05dd42c16 Enhance README and implement RBAC system with Authentik integration
- 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.
2025-05-31 15:58:41 +08:00

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>