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

703 lines
24 KiB
Vue

<script setup>
definePageMeta({
title: "Create Application",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications" },
{ name: "Create Application", path: "/applications/create", type: "current" }
]
});
import { ref, reactive, computed, onMounted } from 'vue'
// Simplified form state
const applicationForm = reactive({
// Step 1: Basic Info
name: '',
slug: '',
description: '',
launchUrl: '',
icon: null,
// Step 2: Quick Setup (NEW - primary method)
useQuickSetup: true,
setupType: '',
// Advanced Configuration (hidden by default)
showAdvancedConfig: false,
publisher: '',
status: 'development',
// Provider Configuration (simplified)
providerType: 'oauth2',
clientId: '',
clientSecret: '',
redirectUris: '',
// Access Control (simplified)
policyEngineMode: 'any',
selectedGroups: [],
// Authentik Integration
syncToAuthentik: true,
createProvider: true
})
// Step management
const currentStep = ref(1)
const totalSteps = 3
// Quick setup types with smart defaults
const setupTypes = ref([
{
id: 'web-app',
name: '🌐 Web Application',
description: 'Standard web application with OAuth2 authentication',
icon: 'ph:globe',
color: 'blue',
recommended: true,
defaults: {
providerType: 'oauth2',
status: 'development',
policyEngineMode: 'any',
createProvider: true
}
},
{
id: 'api-service',
name: '🔌 API Service',
description: 'Backend API or microservice with service-to-service auth',
icon: 'ph:api',
color: 'green',
defaults: {
providerType: 'oauth2',
status: 'development',
policyEngineMode: 'all',
createProvider: true
}
},
{
id: 'enterprise-app',
name: '🏢 Enterprise Application',
description: 'Enterprise app with SAML SSO and strict policies',
icon: 'ph:building',
color: 'purple',
defaults: {
providerType: 'saml',
status: 'development',
policyEngineMode: 'all',
createProvider: true
}
},
{
id: 'custom',
name: '⚙️ Custom Configuration',
description: 'Manual configuration for specific requirements',
icon: 'ph:gear',
color: 'gray',
defaults: {
providerType: 'oauth2',
status: 'development',
policyEngineMode: 'any',
createProvider: false
}
}
])
// Simplified provider types
const providerTypes = ref([
{ value: 'oauth2', label: '🔐 OAuth2/OIDC', description: 'Modern web authentication (Recommended)' },
{ value: 'saml', label: '🏢 SAML', description: 'Enterprise SSO standard' },
{ value: 'proxy', label: '🔄 Proxy', description: 'Forward authentication proxy' }
])
// Available groups (simplified)
const availableGroups = ref([
{ id: '1', name: 'All Users', description: 'Everyone can access', users: 120 },
{ id: '2', name: 'IT Department', description: 'IT team members', users: 15 },
{ id: '3', name: 'Management', description: 'Managers and executives', users: 8 },
{ id: '4', name: 'HR Department', description: 'Human resources team', users: 6 }
])
// Loading states
const isLoading = ref(false)
// Computed properties
const selectedSetupType = computed(() => {
return setupTypes.value.find(t => t.id === applicationForm.setupType)
})
const isFormValid = computed(() => {
if (currentStep.value === 1) {
return applicationForm.name && applicationForm.description && applicationForm.launchUrl
}
if (currentStep.value === 2) {
return applicationForm.useQuickSetup ? applicationForm.setupType : true
}
return true
})
const hasRequiredOAuth2Fields = computed(() => {
if (applicationForm.providerType !== 'oauth2') return true
return applicationForm.clientId && applicationForm.clientSecret && applicationForm.redirectUris
})
// Methods
const generateSlug = () => {
if (applicationForm.name) {
applicationForm.slug = applicationForm.name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
}
const selectSetupType = (typeId) => {
applicationForm.setupType = typeId
applicationForm.useQuickSetup = true
applicationForm.showAdvancedConfig = false
// Apply defaults
const setupType = setupTypes.value.find(t => t.id === typeId)
if (setupType && typeId !== 'custom') {
Object.assign(applicationForm, setupType.defaults)
// Auto-generate OAuth2 credentials if needed
if (setupType.defaults.providerType === 'oauth2') {
generateClientCredentials()
}
} else if (typeId === 'custom') {
applicationForm.showAdvancedConfig = true
applicationForm.createProvider = false
}
}
const generateClientCredentials = () => {
applicationForm.clientId = 'app_' + Math.random().toString(36).substring(2, 15)
applicationForm.clientSecret = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
// Auto-generate redirect URI based on launch URL
if (applicationForm.launchUrl) {
try {
const url = new URL(applicationForm.launchUrl)
applicationForm.redirectUris = `${url.origin}/auth/callback`
} catch (e) {
applicationForm.redirectUris = 'https://yourapp.com/auth/callback'
}
}
}
const toggleAdvancedConfig = () => {
applicationForm.showAdvancedConfig = !applicationForm.showAdvancedConfig
if (applicationForm.showAdvancedConfig) {
applicationForm.useQuickSetup = false
applicationForm.setupType = 'custom'
}
}
const nextStep = () => {
if (currentStep.value < totalSteps && isFormValid.value) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 1) {
currentStep.value--
}
}
const toggleGroupSelection = (groupId) => {
const index = applicationForm.selectedGroups.indexOf(groupId)
if (index > -1) {
applicationForm.selectedGroups.splice(index, 1)
} else {
applicationForm.selectedGroups.push(groupId)
}
}
const createApplication = async () => {
if (!isFormValid.value || !hasRequiredOAuth2Fields.value) return
isLoading.value = true
try {
const applicationData = {
name: applicationForm.name,
slug: applicationForm.slug,
description: applicationForm.description,
launchUrl: applicationForm.launchUrl,
icon: applicationForm.icon,
publisher: applicationForm.publisher || 'IT Department',
status: applicationForm.status,
setupType: applicationForm.useQuickSetup ? applicationForm.setupType : 'custom',
provider: {
type: applicationForm.providerType,
clientId: applicationForm.clientId,
clientSecret: applicationForm.clientSecret,
redirectUris: applicationForm.redirectUris.split('\n').filter(uri => uri.trim())
},
policyEngineMode: applicationForm.policyEngineMode,
selectedGroups: applicationForm.selectedGroups,
syncToAuthentik: applicationForm.syncToAuthentik,
createProvider: applicationForm.createProvider
}
console.log('Creating application:', applicationData)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000))
// Success - redirect
await navigateTo('/applications')
} catch (error) {
console.error('Failed to create application:', error)
} finally {
isLoading.value = false
}
}
const resetForm = () => {
Object.assign(applicationForm, {
name: '',
slug: '',
description: '',
launchUrl: '',
icon: null,
useQuickSetup: true,
setupType: '',
showAdvancedConfig: false,
publisher: '',
status: 'development',
providerType: 'oauth2',
clientId: '',
clientSecret: '',
redirectUris: '',
policyEngineMode: 'any',
selectedGroups: [],
syncToAuthentik: true,
createProvider: true
})
currentStep.value = 1
}
// Initialize
onMounted(() => {
// Set default setup type
applicationForm.setupType = 'web-app'
applicationForm.selectedGroups = ['1'] // Default to "All Users"
})
</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">Add New Application</h1>
<p class="text-gray-600 dark:text-gray-400">Create and configure an application with Authentik integration</p>
</div>
<div class="flex space-x-3">
<rs-button @click="resetForm" variant="primary-outline">
<Icon name="ph:arrow-clockwise" class="w-4 h-4 mr-2" />
Reset
</rs-button>
<rs-button
v-if="currentStep === totalSteps"
@click="createApplication"
:disabled="!isFormValid || !hasRequiredOAuth2Fields || isLoading"
>
<Icon name="ph:plus" class="w-4 h-4 mr-2" />
{{ isLoading ? 'Creating...' : 'Create Application' }}
</rs-button>
</div>
</div>
</div>
<!-- Step Progress -->
<div class="mb-8">
<div class="flex items-center justify-center space-x-4">
<div
v-for="step in totalSteps"
:key="step"
class="flex items-center"
>
<div
class="flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors"
:class="{
'border-primary bg-primary text-white': currentStep >= step,
'border-gray-300 text-gray-300': currentStep < step
}"
>
<Icon v-if="currentStep > step" name="ph:check" class="w-5 h-5" />
<span v-else class="text-sm font-medium">{{ step }}</span>
</div>
<div v-if="step < totalSteps" class="w-16 h-0.5 mx-4"
:class="currentStep > step ? 'bg-primary' : 'bg-gray-300'"></div>
</div>
</div>
<div class="text-center mt-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Step {{ currentStep }} of {{ totalSteps }}:
{{ currentStep === 1 ? 'Basic Information' : currentStep === 2 ? 'Configuration' : 'Access Control' }}
</span>
</div>
</div>
<!-- Step 1: Basic Information -->
<div v-if="currentStep === 1">
<rs-card>
<template #header>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Application Details</h3>
</template>
<template #body>
<FormKit type="form" :actions="false">
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="applicationForm.name"
type="text"
label="Application Name"
placeholder="e.g., Employee Portal"
validation="required"
validation-visibility="dirty"
@input="generateSlug"
help="Display name for your application"
/>
<FormKit
v-model="applicationForm.slug"
type="text"
label="URL Slug"
placeholder="employee-portal"
validation="required"
validation-visibility="dirty"
help="URL-friendly identifier (auto-generated)"
disabled
/>
</div>
<FormKit
v-model="applicationForm.description"
type="textarea"
label="Description"
placeholder="Brief description of what this application does"
validation="required"
validation-visibility="dirty"
rows="3"
help="Explain the purpose and features of this application"
/>
<FormKit
v-model="applicationForm.launchUrl"
type="url"
label="Application URL"
placeholder="https://portal.company.com"
validation="required|url"
validation-visibility="dirty"
help="Where users will access your application"
/>
<FormKit
v-model="applicationForm.icon"
type="file"
label="Application Icon (Optional)"
help="Upload an icon for the application (PNG, JPG, SVG)"
accept="image/*"
/>
</div>
</FormKit>
</template>
</rs-card>
</div>
<!-- Step 2: Quick Setup or Advanced Configuration -->
<div v-if="currentStep === 2">
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Configuration Method</h3>
<rs-badge variant="info">Choose Setup Type</rs-badge>
</div>
</template>
<template #body>
<div class="space-y-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Select a setup type for quick configuration with smart defaults, or choose custom for manual setup.
</p>
<!-- Quick Setup Types -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="setupType in setupTypes"
:key="setupType.id"
@click="selectSetupType(setupType.id)"
class="cursor-pointer p-5 border-2 rounded-lg transition-all hover:shadow-md relative"
:class="{
'border-primary bg-primary/5': applicationForm.setupType === setupType.id,
'border-gray-200 dark:border-gray-700 hover:border-primary/50': applicationForm.setupType !== setupType.id
}"
>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-lg flex items-center justify-center"
:class="`bg-${setupType.color}-100 dark:bg-${setupType.color}-900/30`">
<Icon :name="setupType.icon" :class="`w-6 h-6 text-${setupType.color}-600 dark:text-${setupType.color}-400`" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-2">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">
{{ setupType.name }}
</h4>
<rs-badge v-if="setupType.recommended" variant="success" size="xs">Recommended</rs-badge>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
{{ setupType.description }}
</p>
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-500">
Auto-configured
</span>
<Icon
v-if="applicationForm.setupType === setupType.id"
name="ph:check-circle-fill"
class="w-5 h-5 text-primary"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Configuration Toggle -->
<div v-if="applicationForm.setupType && applicationForm.setupType !== 'custom'" class="pt-4 border-t border-gray-200 dark:border-gray-700">
<rs-button
@click="toggleAdvancedConfig"
variant="secondary-outline"
size="sm"
class="w-full"
>
<Icon name="ph:gear" class="w-4 h-4 mr-2" />
{{ applicationForm.showAdvancedConfig ? 'Hide' : 'Show' }} Advanced Settings
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Advanced Configuration (Hidden by default) -->
<rs-card v-if="applicationForm.showAdvancedConfig || applicationForm.setupType === 'custom'" class="mt-6">
<template #header>
<div class="flex items-center">
<Icon name="ph:gear" class="w-5 h-5 mr-2 text-orange-600" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Advanced Configuration</h3>
<rs-badge variant="warning" class="ml-2">Expert Mode</rs-badge>
</div>
</template>
<template #body>
<div class="space-y-6">
<div class="p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<div class="flex items-start">
<Icon name="ph:warning" class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<p class="font-medium mb-1">Advanced Configuration</p>
<p>Configure technical settings manually. Most applications should use the quick setup above.</p>
</div>
</div>
</div>
<!-- Basic Advanced Settings -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="applicationForm.publisher"
type="text"
label="Publisher"
placeholder="IT Department"
help="Organization or team responsible"
/>
<FormKit
v-model="applicationForm.status"
type="select"
label="Status"
:options="[
{ value: 'development', label: 'Development' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' }
]"
/>
</div>
<!-- Provider Configuration -->
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Provider Settings</h4>
<div class="space-y-4">
<FormKit
v-model="applicationForm.providerType"
type="select"
label="Provider Type"
:options="providerTypes"
/>
<!-- OAuth2 specific settings -->
<div v-if="applicationForm.providerType === 'oauth2'" class="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex items-center justify-between mb-3">
<h5 class="text-sm font-medium text-gray-900 dark:text-white">OAuth2 Credentials</h5>
<rs-button @click="generateClientCredentials" variant="primary-outline" size="xs">
<Icon name="ph:key" class="w-3 h-3 mr-1" />
Generate
</rs-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="applicationForm.clientId"
type="text"
label="Client ID"
placeholder="app_abc123"
validation="required"
validation-visibility="dirty"
/>
<FormKit
v-model="applicationForm.clientSecret"
type="password"
label="Client Secret"
placeholder="••••••••••••••••"
validation="required"
validation-visibility="dirty"
/>
</div>
<FormKit
v-model="applicationForm.redirectUris"
type="text"
label="Redirect URI"
placeholder="https://yourapp.com/auth/callback"
validation="required"
validation-visibility="dirty"
help="Where users return after authentication"
/>
</div>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Step 3: Access Control -->
<div v-if="currentStep === 3">
<rs-card>
<template #header>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Who Can Access This Application?</h3>
</template>
<template #body>
<div class="space-y-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Select which groups should have access to this application. You can modify these settings later.
</p>
<!-- Group Selection -->
<div class="space-y-3">
<div
v-for="group in availableGroups"
:key="group.id"
@click="toggleGroupSelection(group.id)"
class="cursor-pointer p-4 border-2 rounded-lg transition-all hover:shadow-sm"
:class="{
'border-primary bg-primary/5': applicationForm.selectedGroups.includes(group.id),
'border-gray-200 dark:border-gray-700 hover:border-primary/50': !applicationForm.selectedGroups.includes(group.id)
}"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<Icon name="ph:users-three" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">{{ group.name }}</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ group.description }}</p>
<span class="text-xs text-gray-500">{{ group.users }} users</span>
</div>
</div>
<Icon
v-if="applicationForm.selectedGroups.includes(group.id)"
name="ph:check-circle-fill"
class="w-6 h-6 text-primary"
/>
<Icon
v-else
name="ph:circle"
class="w-6 h-6 text-gray-300"
/>
</div>
</div>
</div>
<!-- Policy Engine Mode (simplified) -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<FormKit
v-model="applicationForm.policyEngineMode"
type="radio"
label="Access Policy"
:options="[
{ value: 'any', label: 'User needs access to ANY selected group' },
{ value: 'all', label: 'User needs access to ALL selected groups' }
]"
help="How multiple group memberships are evaluated"
/>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Navigation Buttons -->
<div class="flex items-center justify-between mt-8">
<rs-button
v-if="currentStep > 1"
@click="prevStep"
variant="secondary"
>
<Icon name="ph:arrow-left" class="w-4 h-4 mr-2" />
Previous
</rs-button>
<div v-else></div>
<rs-button
v-if="currentStep < totalSteps"
@click="nextStep"
:disabled="!isFormValid"
variant="primary"
>
Next Step
<Icon name="ph:arrow-right" class="w-4 h-4 ml-2" />
</rs-button>
</div>
</div>
</template>
<style scoped>
input:focus {
@apply ring-2 ring-offset-2;
}
select:focus {
@apply ring-2 ring-offset-2;
}
</style>