Refactor Application Creation and Management Logic

- Simplified the application creation process by consolidating form fields and enhancing validation.
- Updated the create application page to streamline user experience with clearer provider options and improved layout.
- Implemented SweetAlert for success and error notifications during user actions, replacing traditional alerts.
- Enhanced the applications index page with dynamic filtering and improved data fetching from the Authentik API.
- Refactored API endpoints to utilize slugs for application identification, ensuring consistency with Authentik's structure.
- Improved authentication handling by updating the requireAuth utility to support cookie-based authentication.
This commit is contained in:
Md Afiq Iskandar 2025-06-17 11:53:15 +08:00
parent bb98dc0262
commit ef5526baf1
15 changed files with 2190 additions and 924 deletions

View File

@ -35,10 +35,10 @@ export function useVoiceReader() {
};
const handleKeydown = (event) => {
if (event.ctrlKey && event.key === "r") {
event.preventDefault();
toggleReading();
}
// if (event.ctrlKey && event.key === "r") {
// event.preventDefault();
// toggleReading();
// }
};
const announce = (message) => {

View File

@ -0,0 +1,588 @@
<script setup>
definePageMeta({
title: "Edit Application",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications" },
{ name: "Edit Application", path: "", type: "current" },
],
});
import { ref, reactive, computed, onMounted } from "vue";
// Get application ID from route
const route = useRoute();
const applicationId = route.params.id;
const { $swal } = useNuxtApp();
// Form state - exactly like create page but will be populated with existing data
const applicationForm = reactive({
name: "",
slug: "",
description: "",
launchUrl: "",
setupType: "web-app", // Default to most common type
providerType: "oauth2", // Default to most common provider
});
// Loading states
const isLoading = ref(true);
const isSaving = ref(false);
// Original application data for comparison
const originalApplication = ref(null);
// Super simple setup types - no tech jargon (exactly like create page)
const setupTypes = ref([
{
id: "web-app",
name: "🌐 Website/Web App",
description: "For websites and web applications that users log into",
popular: true,
},
{
id: "mobile-app",
name: "📱 Mobile App",
description: "For mobile apps (iPhone, Android) that need login",
popular: false,
},
{
id: "desktop-app",
name: "💻 Desktop App",
description: "For desktop software that needs user authentication",
popular: false,
},
]);
// Simple provider presets - industry standards (exactly like create page)
const providerTypes = ref([
{
id: "oauth2",
name: "🔐 OAuth2/OIDC",
description: "Modern login standard - works with most apps (Recommended)",
popular: true,
technical: "OAuth2/OpenID Connect",
},
{
id: "saml",
name: "🏢 SAML SSO",
description: "Enterprise single sign-on - common in large companies",
popular: false,
technical: "SAML 2.0",
},
{
id: "ldap",
name: "📁 LDAP Directory",
description: "Connect to Active Directory or LDAP servers",
popular: false,
technical: "LDAP/Active Directory",
},
{
id: "proxy",
name: "🔄 Proxy Auth",
description: "For older apps that don't support modern login",
popular: false,
technical: "Forward Auth Proxy",
},
]);
// Fetch existing application data
const fetchApplication = async () => {
try {
isLoading.value = true;
const response = await $fetch(`/api/applications/${applicationId}`);
// Store original data
originalApplication.value = response;
// Populate form with existing data
applicationForm.name = response.name || "";
applicationForm.slug = response.slug || "";
applicationForm.description = response.meta_description || "";
applicationForm.launchUrl =
response.launch_url || response.meta_launch_url || "";
// Try to detect the setup type and provider type from existing data
// This is a best guess based on available information
applicationForm.setupType = "web-app"; // Default since we can't reliably detect this
// Try to detect provider type from provider_obj
if (response.provider_obj?.verbose_name_plural) {
const providerName =
response.provider_obj.verbose_name_plural.toLowerCase();
if (providerName.includes("oauth")) {
applicationForm.providerType = "oauth2";
} else if (providerName.includes("saml")) {
applicationForm.providerType = "saml";
} else if (providerName.includes("ldap")) {
applicationForm.providerType = "ldap";
} else if (providerName.includes("proxy")) {
applicationForm.providerType = "proxy";
}
}
} catch (error) {
console.error("Failed to fetch application:", error);
// Use SweetAlert instead of toast
$swal.fire({
icon: 'error',
title: 'Error',
text: 'Failed to load application data. Please try again.'
});
await navigateTo("/applications");
} finally {
isLoading.value = false;
}
};
// Simple validation (exactly like create page)
const isFormValid = computed(() => {
return (
applicationForm.name &&
applicationForm.description &&
applicationForm.launchUrl &&
applicationForm.setupType &&
applicationForm.providerType
);
});
// Check if form has changes
const hasChanges = computed(() => {
if (!originalApplication.value) return false;
return (
applicationForm.name !== originalApplication.value.name ||
applicationForm.slug !== originalApplication.value.slug ||
applicationForm.description !==
(originalApplication.value.meta_description || "") ||
applicationForm.launchUrl !==
(originalApplication.value.launch_url ||
originalApplication.value.meta_launch_url ||
"")
);
});
// Auto-generate slug when name changes (exactly like create page)
const generateSlug = () => {
if (
applicationForm.name &&
applicationForm.name !== originalApplication.value?.name
) {
applicationForm.slug = applicationForm.name
.toLowerCase()
.replace(/[^a-z0-9]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
};
// Update application - super simplified (like create page)
const updateApplication = async () => {
if (!isFormValid.value) return;
isSaving.value = true;
try {
const applicationData = {
name: applicationForm.name,
slug: applicationForm.slug,
meta_description: applicationForm.description,
meta_launch_url: applicationForm.launchUrl,
meta_publisher: "IT Department",
setupType: applicationForm.setupType,
providerType: applicationForm.providerType,
};
console.log("Updating application:", applicationData);
const response = await $fetch(`/api/applications/${applicationId}`, {
method: "PUT",
body: applicationData,
});
console.log("Application updated successfully:", response);
// Show success message with SweetAlert instead of toast
$swal.fire({
icon: 'success',
title: 'Success',
text: response.message || "Application updated successfully!",
timer: 2000,
showConfirmButton: false
});
// Success - redirect to view page after short delay using the new slug
setTimeout(async () => {
// Use the updated slug from the response instead of the old applicationId
const newSlug = response.slug || applicationId;
await navigateTo(`/applications/${newSlug}`);
}, 1000);
} catch (error) {
console.error("Failed to update application:", error);
// Show error message with SweetAlert instead of toast
let errorMessage = "Failed to update application. ";
if (error.data?.message) {
errorMessage += error.data.message;
} else {
errorMessage += "Please check the details and try again.";
}
$swal.fire({
icon: 'error',
title: 'Error',
text: errorMessage
});
} finally {
isSaving.value = false;
}
};
// Get dynamic info based on selected provider (updated for edit context)
const getProviderInfo = () => {
const providerInfo = {
oauth2: {
title: "OAuth2 configuration will be updated",
description:
"Changes to OAuth2 settings may require updating your application's callback URLs and client configuration.",
},
saml: {
title: "SAML SSO configuration will be updated",
description:
"Changes to SAML settings may require updating your application's SAML configuration and certificates.",
},
ldap: {
title: "LDAP directory integration will be updated",
description:
"Changes to LDAP settings may affect how users authenticate with their network credentials.",
},
proxy: {
title: "Proxy authentication will be updated",
description:
"Changes to proxy settings may affect how the authentication proxy forwards requests to your application.",
},
};
return providerInfo[applicationForm.providerType] || providerInfo.oauth2;
};
// Reset form to original values
const resetForm = () => {
if (originalApplication.value) {
applicationForm.name = originalApplication.value.name || "";
applicationForm.slug = originalApplication.value.slug || "";
applicationForm.description =
originalApplication.value.meta_description || "";
applicationForm.launchUrl =
originalApplication.value.launch_url ||
originalApplication.value.meta_launch_url ||
"";
}
};
// Initialize
onMounted(() => {
fetchApplication();
});
</script>
<template>
<div>
<LayoutsBreadcrumb />
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="text-center">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"
></div>
<p class="text-gray-600 dark:text-gray-400 mt-4">
Loading application data...
</p>
</div>
</div>
<!-- Edit Form -->
<div v-else>
<!-- 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">
Edit Application
</h1>
<p class="text-gray-600 dark:text-gray-400">
Update application configuration and settings
</p>
</div>
<div class="flex space-x-3">
<rs-button
@click="navigateTo(`/applications/${applicationId}`)"
variant="secondary"
>
<Icon name="ph:arrow-left" class="w-4 h-4 mr-2" />
Cancel
</rs-button>
<rs-button
@click="resetForm"
variant="primary-outline"
:disabled="!hasChanges"
>
<Icon name="ph:arrow-clockwise" class="w-4 h-4 mr-2" />
Reset Changes
</rs-button>
<rs-button
@click="updateApplication"
:disabled="!isFormValid || !hasChanges || isSaving"
variant="primary"
>
<Icon
v-if="isSaving"
name="ph:spinner"
class="w-4 h-4 mr-2 animate-spin"
/>
<Icon v-else name="ph:floppy-disk" class="w-4 h-4 mr-2" />
{{ isSaving ? "Saving..." : "Save Changes" }}
</rs-button>
</div>
</div>
</div>
<!-- Changes Indicator -->
<div v-if="hasChanges" class="mb-6">
<div
class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4"
>
<div class="flex items-center">
<Icon
name="ph:warning"
class="w-5 h-5 text-orange-600 dark:text-orange-400 mr-2"
/>
<span class="text-sm text-orange-700 dark:text-orange-300">
You have unsaved changes. Don't forget to save your modifications.
</span>
</div>
</div>
</div>
<!-- Single Card Form - Exactly like create page -->
<rs-card>
<template #header>
<Icon name="ph:pencil" class="text-blue-600" />
<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">
<!-- Basic Information -->
<div class="space-y-4">
<h4
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Basic Information
</h4>
<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="What should we call this application?"
/>
<FormKit
v-model="applicationForm.slug"
type="text"
label="URL Identifier"
placeholder="employee-portal"
validation="required"
validation-visibility="dirty"
help="Auto-generated from name"
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 what users will use this application for"
/>
<FormKit
v-model="applicationForm.launchUrl"
type="url"
label="Application Website"
placeholder="https://portal.company.com"
validation="required|url"
validation-visibility="dirty"
help="Where users will go to access this application"
/>
</div>
<!-- Application Type -->
<div class="space-y-4">
<h4
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Application Type
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="type in setupTypes"
:key="type.id"
@click="applicationForm.setupType = type.id"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-colors hover:border-blue-500"
:class="{
'border-blue-500 bg-blue-50 dark:bg-blue-900/20':
applicationForm.setupType === type.id,
'border-gray-200 dark:border-gray-700':
applicationForm.setupType !== type.id,
}"
>
<div class="flex items-start space-x-3">
<div class="text-2xl">{{ type.name.split(" ")[0] }}</div>
<div class="flex-1">
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
{{ type.name.substring(2) }}
</h3>
<p
class="text-xs text-gray-600 dark:text-gray-400 mt-1"
>
{{ type.description }}
</p>
<div v-if="type.popular" class="mt-2">
<rs-badge variant="primary">Most Popular</rs-badge>
</div>
</div>
</div>
<div
v-if="applicationForm.setupType === type.id"
class="absolute top-2 right-2"
>
<Icon
name="ph:check-circle-fill"
class="w-5 h-5 text-blue-500"
/>
</div>
</div>
</div>
</div>
<!-- Authentication Provider -->
<div class="space-y-4">
<h4
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Authentication Method
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Choose how users will log into this application
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="provider in providerTypes"
:key="provider.id"
@click="applicationForm.providerType = provider.id"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-colors hover:border-green-500"
:class="{
'border-green-500 bg-green-50 dark:bg-green-900/20':
applicationForm.providerType === provider.id,
'border-gray-200 dark:border-gray-700':
applicationForm.providerType !== provider.id,
}"
>
<div class="flex items-start space-x-3">
<div class="text-xl">
{{ provider.name.split(" ")[0] }}
</div>
<div class="flex-1">
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
{{ provider.name.substring(2) }}
</h3>
<p
class="text-xs text-gray-600 dark:text-gray-400 mt-1"
>
{{ provider.description }}
</p>
<div class="flex items-center space-x-2 mt-2">
<div v-if="provider.popular" class="mt-1">
<rs-badge variant="success" size="xs"
>Recommended</rs-badge
>
</div>
<span class="text-xs text-gray-500">{{
provider.technical
}}</span>
</div>
</div>
</div>
<div
v-if="applicationForm.providerType === provider.id"
class="absolute top-2 right-2"
>
<Icon
name="ph:check-circle-fill"
class="w-5 h-5 text-green-500"
/>
</div>
</div>
</div>
</div>
<!-- Dynamic Info Box Based on Provider -->
<div
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"
>
<div class="flex items-start space-x-3">
<Icon
name="ph:info"
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5"
/>
<div class="flex-1">
<h4
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>
{{ getProviderInfo().title }}
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
{{ getProviderInfo().description }}
</p>
</div>
</div>
</div>
</div>
</FormKit>
</template>
</rs-card>
</div>
</div>
</template>
<style scoped>
input:focus {
@apply ring-2 ring-offset-2;
}
select:focus {
@apply ring-2 ring-offset-2;
}
</style>

View File

@ -0,0 +1,316 @@
<script setup>
definePageMeta({
title: "View Application",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications" },
{ name: "View Application", path: "", type: "current" },
],
});
import { ref, onMounted, computed } from "vue";
// Get application ID from route
const route = useRoute();
const applicationId = route.params.id;
// No need for separate toast reference as we're using $swal
// State management
const application = ref(null);
const isLoading = ref(true);
const isDeleting = ref(false);
// Fetch application details
const fetchApplication = async () => {
try {
isLoading.value = true;
const response = await $fetch(`/api/applications/${applicationId}`);
// Transform response to match our frontend format
application.value = {
id: response.slug,
name: response.name,
slug: response.slug,
description: response.meta_description || "No description provided",
status: "active", // Authentik applications are active by default
provider: response.provider_obj?.verbose_name || response.provider_obj?.name || "No Provider",
authentikId: response.pk,
launchUrl: response.launch_url || response.meta_launch_url || "#",
icon: response.meta_icon || null,
publisher: response.meta_publisher || "System",
createdAt: response.created || new Date().toISOString(),
providerType: response.provider_obj?.verbose_name_plural || "Unknown",
providerDetails: response.provider_obj || null,
};
} catch (error) {
console.error("Failed to fetch application:", error);
application.value = null;
} finally {
isLoading.value = false;
}
};
// Delete application
const deleteApplication = async () => {
if (
!confirm(
"Are you sure you want to delete this application? This action cannot be undone and will remove all associated configurations."
)
) {
return;
}
try {
isDeleting.value = true;
await $fetch(`/api/applications/${applicationId}`, {
method: "DELETE",
});
console.log("✅ Application deleted successfully");
// Use SweetAlert instead of toast
const { $swal } = useNuxtApp();
$swal.fire({
icon: 'success',
title: 'Success',
text: 'Application deleted successfully!',
timer: 2000,
showConfirmButton: false
});
// Redirect to applications list
await navigateTo("/applications");
} catch (error) {
console.error("❌ Failed to delete application:", error);
let errorMessage = "Failed to delete application. ";
if (error.data?.message) {
errorMessage += error.data.message;
} else if (error.statusCode === 404) {
errorMessage += "Application not found.";
} else if (error.statusCode === 401) {
errorMessage += "You are not authorized to delete this application.";
} else {
errorMessage += "Please try again.";
}
const { $swal } = useNuxtApp();
$swal.fire({
icon: 'error',
title: 'Error',
text: errorMessage
});
} finally {
isDeleting.value = false;
}
};
// Computed properties
const providerTypeIcon = computed(() => {
if (!application.value?.providerDetails) return "ph:shield";
const providerType = application.value.providerDetails.verbose_name_plural?.toLowerCase();
if (providerType?.includes("oauth")) return "ph:key";
if (providerType?.includes("saml")) return "ph:certificate";
if (providerType?.includes("ldap")) return "ph:tree-structure";
if (providerType?.includes("proxy")) return "ph:arrows-left-right";
return "ph:shield";
});
const statusVariant = computed(() => {
return application.value?.status === "active" ? "success" : "secondary";
});
const formatDate = (dateString) => {
if (!dateString) return "Never";
return new Date(dateString).toLocaleDateString() + " " + new Date(dateString).toLocaleTimeString();
};
// Initialize
onMounted(() => {
fetchApplication();
});
</script>
<template>
<div>
<LayoutsBreadcrumb />
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="text-gray-600 dark:text-gray-400 mt-4">Loading application details...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="!application" class="text-center py-12">
<Icon name="ph:warning-circle" class="w-16 h-16 text-red-500 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Application Not Found</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
The application you're looking for doesn't exist or you don't have permission to view it.
</p>
<rs-button @click="navigateTo('/applications')" variant="primary">
<Icon name="ph:arrow-left" class="w-4 h-4 mr-2" />
Back to Applications
</rs-button>
</div>
<!-- Application Details -->
<div v-else>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<div class="h-12 w-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span class="text-lg font-bold text-white">
{{ application.name ? application.name.charAt(0).toUpperCase() : "?" }}
</span>
</div>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ application.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400">
{{ application.description }}
</p>
</div>
</div>
<div class="flex space-x-3">
<rs-button @click="navigateTo('/applications')" variant="secondary">
<Icon name="ph:arrow-left" class="w-4 h-4 mr-2" />
Back to List
</rs-button>
<rs-button @click="navigateTo(`/applications/${applicationId}/edit`)" variant="primary-outline">
<Icon name="ph:pencil" class="w-4 h-4 mr-2" />
Edit Application
</rs-button>
<rs-button
@click="deleteApplication"
:disabled="isDeleting"
variant="danger"
>
<Icon name="ph:trash" class="w-4 h-4 mr-2" />
{{ isDeleting ? 'Deleting...' : 'Delete' }}
</rs-button>
</div>
</div>
</div>
<!-- Application Information Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Basic Information -->
<rs-card>
<template #header>
<Icon name="ph:info" class="text-blue-600" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</span>
<rs-badge :variant="statusVariant">{{ application.status }}</rs-badge>
</div>
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Application ID</span>
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ application.slug }}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Launch URL</span>
<a
:href="application.launchUrl"
target="_blank"
class="text-sm text-primary hover:underline flex items-center"
>
{{ application.launchUrl }}
<Icon name="ph:arrow-square-out" class="w-3 h-3 ml-1" />
</a>
</div>
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Publisher</span>
<span class="text-sm text-gray-900 dark:text-white">{{ application.publisher }}</span>
</div>
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Created</span>
<span class="text-sm text-gray-900 dark:text-white">{{ formatDate(application.createdAt) }}</span>
</div>
</div>
</template>
</rs-card>
<!-- Provider Information -->
<rs-card>
<template #header>
<Icon :name="providerTypeIcon" class="text-green-600" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentication Provider</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Provider Type</span>
<span class="text-sm text-gray-900 dark:text-white">{{ application.provider }}</span>
</div>
<div v-if="application.providerDetails" class="space-y-3">
<div class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Provider ID</span>
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ application.providerDetails.pk }}</span>
</div>
<div v-if="application.providerDetails.client_id" class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Client ID</span>
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ application.providerDetails.client_id }}</span>
</div>
<div v-if="application.providerDetails.authorization_flow_name" class="flex justify-between items-start">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Auth Flow</span>
<span class="text-sm text-gray-900 dark:text-white">{{ application.providerDetails.authorization_flow_name }}</span>
</div>
</div>
<div v-else class="text-center py-4">
<Icon name="ph:warning" class="w-8 h-8 text-orange-500 mx-auto mb-2" />
<p class="text-sm text-orange-600 dark:text-orange-400">No provider configured</p>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Access Control -->
<rs-card>
<template #header>
<Icon name="ph:users" class="text-purple-600" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Access Control</h3>
</template>
<template #body>
<div class="text-center py-8">
<Icon name="ph:shield-check" class="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Access Control Configuration</h4>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Configure which users and groups can access this application.
</p>
<rs-button variant="primary-outline" disabled>
<Icon name="ph:gear" class="w-4 h-4 mr-2" />
Configure Access (Coming Soon)
</rs-button>
</div>
</template>
</rs-card>
</div>
</div>
</template>
<style scoped>
/* Component specific styles */
</style>

View File

@ -6,295 +6,205 @@ definePageMeta({
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications" },
{ name: "Create Application", path: "/applications/create", type: "current" }
]
{
name: "Create Application",
path: "/applications/create",
type: "current",
},
],
});
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed } from "vue";
// Simplified form state
const { $swal } = useNuxtApp();
// Super simplified form - only what users really need
const applicationForm = reactive({
// Step 1: Basic Info
name: '',
slug: '',
description: '',
launchUrl: '',
icon: null,
name: "",
slug: "",
description: "",
launchUrl: "",
setupType: "web-app", // Default to most common type
providerType: "oauth2", // Default to most common provider
});
// Step 2: Quick Setup (NEW - primary method)
useQuickSetup: true,
setupType: '',
// Loading state
const isLoading = ref(false);
// 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
// Super simple setup types - no tech jargon
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: "web-app",
name: "🌐 Website/Web App",
description: "For websites and web applications that users log into",
popular: 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: "mobile-app",
name: "📱 Mobile App",
description: "For mobile apps (iPhone, Android) that need login",
popular: false,
},
{
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: "desktop-app",
name: "💻 Desktop App",
description: "For desktop software that needs user authentication",
popular: false,
},
{
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
// Simple provider presets - industry standards
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)
})
{
id: "oauth2",
name: "🔐 OAuth2/OIDC",
description: "Modern login standard - works with most apps (Recommended)",
popular: true,
technical: "OAuth2/OpenID Connect",
},
{
id: "saml",
name: "🏢 SAML SSO",
description: "Enterprise single sign-on - common in large companies",
popular: false,
technical: "SAML 2.0",
},
{
id: "ldap",
name: "📁 LDAP Directory",
description: "Connect to Active Directory or LDAP servers",
popular: false,
technical: "LDAP/Active Directory",
},
{
id: "proxy",
name: "🔄 Proxy Auth",
description: "For older apps that don't support modern login",
popular: false,
technical: "Forward Auth Proxy",
},
]);
// Simple validation
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
})
return (
applicationForm.name &&
applicationForm.description &&
applicationForm.launchUrl &&
applicationForm.setupType &&
applicationForm.providerType
);
});
const hasRequiredOAuth2Fields = computed(() => {
if (applicationForm.providerType !== 'oauth2') return true
return applicationForm.clientId && applicationForm.clientSecret && applicationForm.redirectUris
})
// Methods
// Auto-generate slug when name changes
const generateSlug = () => {
if (applicationForm.name) {
applicationForm.slug = applicationForm.name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.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)
}
}
};
// Create application - super simplified
const createApplication = async () => {
if (!isFormValid.value || !hasRequiredOAuth2Fields.value) return
if (!isFormValid.value) return;
isLoading.value = true
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,
meta_description: applicationForm.description,
meta_launch_url: applicationForm.launchUrl,
meta_publisher: "IT Department",
setupType: applicationForm.setupType,
providerType: applicationForm.providerType,
};
setupType: applicationForm.useQuickSetup ? applicationForm.setupType : 'custom',
console.log("Creating application:", applicationData);
provider: {
type: applicationForm.providerType,
clientId: applicationForm.clientId,
clientSecret: applicationForm.clientSecret,
redirectUris: applicationForm.redirectUris.split('\n').filter(uri => uri.trim())
},
const response = await $fetch("/api/applications", {
method: "POST",
body: applicationData,
});
policyEngineMode: applicationForm.policyEngineMode,
selectedGroups: applicationForm.selectedGroups,
console.log("Application created successfully:", response);
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')
// Show success toast
$swal.fire({
title: "Application created successfully!",
text: "You can now access the application from the applications list.",
icon: "success",
confirmButtonText: "OK",
});
// Success - redirect back to applications list after short delay
setTimeout(async () => {
await navigateTo("/applications");
}, 1000);
} catch (error) {
console.error('Failed to create application:', error)
} finally {
isLoading.value = false
console.error("Failed to create application:", error);
// Show error toast with specific message
let errorMessage = "Failed to create application. ";
if (error.data?.message) {
errorMessage += error.data.message;
} else {
errorMessage += "Please check the details and try again.";
}
}
$swal.fire({
title: "Failed to create application!",
text: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
} finally {
isLoading.value = false;
}
};
// Get dynamic info based on selected provider
const getProviderInfo = () => {
const providerInfo = {
oauth2: {
title: "OAuth2 will be automatically configured",
description:
"We'll create secure OAuth2 credentials and configure the callback URL. Your app will support modern login standards used by Google, Microsoft, and other major platforms.",
},
saml: {
title: "SAML SSO will be automatically configured",
description:
"We'll set up SAML 2.0 single sign-on with enterprise-grade security. This is perfect for connecting with corporate identity systems and Active Directory.",
},
ldap: {
title: "LDAP directory integration will be configured",
description:
"We'll configure LDAP/Active Directory authentication. Users can log in with their existing network credentials without any additional setup.",
},
proxy: {
title: "Proxy authentication will be configured",
description:
"We'll set up forward authentication for legacy applications. This adds modern login to apps that don't support OAuth2 or SAML natively.",
},
};
return providerInfo[applicationForm.providerType] || providerInfo.oauth2;
};
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"
})
name: "",
slug: "",
description: "",
launchUrl: "",
setupType: "web-app",
providerType: "oauth2",
});
};
</script>
<template>
@ -305,65 +215,52 @@ onMounted(() => {
<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>
<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 an application for your users to access with single sign-on
</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
Clear Form
</rs-button>
<rs-button
v-if="currentStep === totalSteps"
@click="createApplication"
:disabled="!isFormValid || !hasRequiredOAuth2Fields || isLoading"
:disabled="!isFormValid || isLoading"
variant="primary"
>
<Icon name="ph:plus" class="w-4 h-4 mr-2" />
{{ isLoading ? 'Creating...' : 'Create Application' }}
<Icon
v-if="isLoading"
name="ph:spinner"
class="w-4 h-4 mr-2 animate-spin"
/>
<Icon v-else 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">
<!-- Single Card Form - No Complex Steps -->
<rs-card>
<template #header>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Application Details</h3>
<Icon name="ph:app-window" class="text-blue-600" />
<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">
<!-- Basic Information -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Basic Information
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
v-model="applicationForm.name"
@ -373,17 +270,17 @@ onMounted(() => {
validation="required"
validation-visibility="dirty"
@input="generateSlug"
help="Display name for your application"
help="What should we call this application?"
/>
<FormKit
v-model="applicationForm.slug"
type="text"
label="URL Slug"
label="URL Identifier"
placeholder="employee-portal"
validation="required"
validation-visibility="dirty"
help="URL-friendly identifier (auto-generated)"
help="Auto-generated from name"
disabled
/>
</div>
@ -396,300 +293,151 @@ onMounted(() => {
validation="required"
validation-visibility="dirty"
rows="3"
help="Explain the purpose and features of this application"
help="Explain what users will use this application for"
/>
<FormKit
v-model="applicationForm.launchUrl"
type="url"
label="Application URL"
label="Application Website"
placeholder="https://portal.company.com"
validation="required|url"
validation-visibility="dirty"
help="Where users will access your application"
help="Where users will go to access this application"
/>
</div>
<FormKit
v-model="applicationForm.icon"
type="file"
label="Application Icon (Optional)"
help="Upload an icon for the application (PNG, JPG, SVG)"
accept="image/*"
<!-- Application Type -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Application Type
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="type in setupTypes"
:key="type.id"
@click="applicationForm.setupType = type.id"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-colors hover:border-blue-500"
:class="{
'border-blue-500 bg-blue-50 dark:bg-blue-900/20':
applicationForm.setupType === type.id,
'border-gray-200 dark:border-gray-700':
applicationForm.setupType !== type.id,
}"
>
<div class="flex items-start space-x-3">
<div class="text-2xl">{{ type.name.split(" ")[0] }}</div>
<div class="flex-1">
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
{{ type.name.substring(2) }}
</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{{ type.description }}
</p>
<div v-if="type.popular" class="mt-2">
<rs-badge variant="primary">Most Popular</rs-badge>
</div>
</div>
</div>
<div
v-if="applicationForm.setupType === type.id"
class="absolute top-2 right-2"
>
<Icon
name="ph:check-circle-fill"
class="w-5 h-5 text-blue-500"
/>
</div>
</div>
</div>
</div>
<!-- Authentication Provider -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Authentication Method
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Choose how users will log into this application
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="provider in providerTypes"
:key="provider.id"
@click="applicationForm.providerType = provider.id"
class="relative p-4 border-2 rounded-lg cursor-pointer transition-colors hover:border-green-500"
:class="{
'border-green-500 bg-green-50 dark:bg-green-900/20':
applicationForm.providerType === provider.id,
'border-gray-200 dark:border-gray-700':
applicationForm.providerType !== provider.id,
}"
>
<div class="flex items-start space-x-3">
<div class="text-xl">{{ provider.name.split(" ")[0] }}</div>
<div class="flex-1">
<h3
class="text-sm font-medium text-gray-900 dark:text-white"
>
{{ provider.name.substring(2) }}
</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{{ provider.description }}
</p>
<div class="flex items-center space-x-2 mt-2">
<div v-if="provider.popular" class="mt-1">
<rs-badge variant="success" size="xs"
>Recommended</rs-badge
>
</div>
<span class="text-xs text-gray-500">{{
provider.technical
}}</span>
</div>
</div>
</div>
<div
v-if="applicationForm.providerType === provider.id"
class="absolute top-2 right-2"
>
<Icon
name="ph:check-circle-fill"
class="w-5 h-5 text-green-500"
/>
</div>
</div>
</div>
</div>
<!-- Dynamic Info Box Based on Provider -->
<div
class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"
>
<div class="flex items-start space-x-3">
<Icon
name="ph:info"
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5"
/>
<div class="flex-1">
<h4
class="text-sm font-medium text-blue-900 dark:text-blue-100"
>
{{ getProviderInfo().title }}
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
{{ getProviderInfo().description }}
</p>
</div>
</div>
</div>
</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>

View File

@ -5,144 +5,244 @@ definePageMeta({
requiresAuth: true,
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications", type: "current" }
]
{ name: "Applications", path: "/applications", type: "current" },
],
});
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed } from "vue";
// Sample application data (same as before)
const applications = ref([
{
id: '1',
name: 'corradAF',
slug: 'corradaf',
description: 'Main RBAC Application',
status: 'active',
provider: 'OAuth2/OIDC',
authentikId: 'authentik-app-1',
launchUrl: 'https://corradaf.company.com',
icon: null,
publisher: 'CorradAF Team',
lastSync: '2024-01-15T10:30:00Z',
syncEnabled: true,
userCount: 45,
groupCount: 8,
roleCount: 12,
resourceCount: 35
},
{
id: '2',
name: 'HR System',
slug: 'hr-system',
description: 'Human Resources Management',
status: 'active',
provider: 'SAML',
authentikId: 'authentik-app-2',
launchUrl: 'https://hr.company.com',
icon: null,
publisher: 'HR Department',
lastSync: '2024-01-15T09:15:00Z',
syncEnabled: true,
userCount: 28,
groupCount: 5,
roleCount: 8,
resourceCount: 22
},
{
id: '3',
name: 'Finance System',
slug: 'finance-system',
description: 'Financial Management Platform',
status: 'development',
provider: 'OAuth2/OIDC',
authentikId: null,
launchUrl: 'https://finance.company.com',
icon: null,
publisher: 'Finance Department',
lastSync: null,
syncEnabled: false,
userCount: 12,
groupCount: 3,
roleCount: 6,
resourceCount: 18
// State management
const applications = ref([]);
const isLoading = ref(true);
const isDeleting = ref(false);
const searchQuery = ref("");
const selectedStatus = ref("all");
const selectedProvider = ref("all");
// Fetch applications from Authentik
const fetchApplications = async () => {
try {
isLoading.value = true;
const response = await $fetch("/api/applications");
// Map Authentik response to our frontend format
applications.value =
response.results?.map((app) => {
// Better provider detection based on actual provider type
let providerName = "No Provider";
if (app.provider_obj) {
providerName =
app.provider_obj.verbose_name ||
app.provider_obj.name ||
"Unknown Provider";
} else if (app.provider) {
// If provider exists but no provider_obj, it might be loading
providerName = "Loading...";
}
])
const isLoading = ref(false)
const isSyncing = ref(false)
const searchQuery = ref('')
const selectedStatus = ref('all')
const selectedProvider = ref('all')
return {
id: app.slug, // Use slug as ID for consistency with Authentik API
name: app.name,
slug: app.slug,
description: app.meta_description || "No description provided",
status: "active", // Authentik applications are active by default
provider: providerName,
authentikId: app.pk, // Keep the original pk for reference
launchUrl: app.launch_url || app.meta_launch_url || "#",
icon: app.meta_icon || null,
publisher: app.meta_publisher || "System",
createdAt: app.created || new Date().toISOString(),
};
}) || [];
} catch (error) {
console.error("Failed to fetch applications:", error);
applications.value = [];
} finally {
isLoading.value = false;
}
};
// Computed properties
const filteredApplications = computed(() => {
let filtered = applications.value.filter(app => app && app.name) // Only include valid apps with names
let filtered = applications.value.filter((app) => app && app.name);
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(app =>
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(
(app) =>
(app.name && app.name.toLowerCase().includes(query)) ||
(app.description && app.description.toLowerCase().includes(query)) ||
(app.publisher && app.publisher.toLowerCase().includes(query))
)
);
}
if (selectedStatus.value !== 'all') {
filtered = filtered.filter(app => app.status === selectedStatus.value)
if (selectedStatus.value !== "all") {
filtered = filtered.filter((app) => app.status === selectedStatus.value);
}
if (selectedProvider.value !== 'all') {
filtered = filtered.filter(app => app.provider === selectedProvider.value)
if (selectedProvider.value !== "all") {
filtered = filtered.filter(
(app) => app.provider === selectedProvider.value
);
}
return filtered
})
return filtered;
});
const stats = computed(() => ({
totalApps: applications.value.filter(app => app && app.name).length,
activeApps: applications.value.filter(app => app && app.status === 'active').length,
totalUsers: applications.value.reduce((sum, app) => sum + (app?.userCount || 0), 0)
}))
totalApps: applications.value.length,
activeApps: applications.value.filter((app) => app.status === "active")
.length,
totalUsers: 0, // We'll need to fetch this from groups/users if needed
}));
const providers = computed(() => {
const uniqueProviders = [...new Set(applications.value
.filter(app => app && app.provider)
.map(app => app.provider))]
return uniqueProviders.map(provider => ({ value: provider, label: provider }))
})
const uniqueProviders = [
...new Set(
applications.value
.filter((app) => app && app.provider)
.map((app) => app.provider)
),
];
return uniqueProviders.map((provider) => ({
value: provider,
label: provider,
}));
});
// Methods
const deleteApplication = async (applicationId) => {
if (!confirm('Are you sure you want to delete this application? This action cannot be undone.')) {
return
const deleteApplication = async (applicationSlug) => {
// Get the application name for the confirmation message
const app = applications.value.find((app) => app.id === applicationSlug);
if (!app) return;
// Check if this is a protected application
if (isProtectedApplication(app.name)) {
const { $swal } = useNuxtApp();
$swal.fire({
icon: "warning",
title: "Protected Application",
text: "This application is used for system authentication and cannot be deleted.",
confirmButtonText: "Understood",
});
return;
}
const index = applications.value.findIndex(app => app.id === applicationId)
// Use SweetAlert for confirmation
const { $swal } = useNuxtApp();
const result = await $swal.fire({
icon: "warning",
title: "Delete Application?",
text: `Are you sure you want to delete "${app.name}"? This action cannot be undone.`,
showCancelButton: true,
confirmButtonText: "Yes, delete it",
confirmButtonColor: "#dc2626",
cancelButtonText: "Cancel",
reverseButtons: true,
});
// If user didn't confirm, abort
if (!result.isConfirmed) return;
console.log(
`🗑️ Frontend: Deleting application with slug: ${applicationSlug}`
);
try {
isDeleting.value = true;
await $fetch(`/api/applications/${applicationSlug}`, {
method: "DELETE",
});
// Remove from local state (now using slug as id)
const index = applications.value.findIndex(
(app) => app.id === applicationSlug
);
if (index > -1) {
applications.value.splice(index, 1)
console.log('Application deleted successfully')
applications.value.splice(index, 1);
}
}
console.log("✅ Application deleted successfully");
// Use SweetAlert instead of alert
const { $swal } = useNuxtApp();
$swal.fire({
icon: "success",
title: "Success",
text: "Application deleted successfully!",
timer: 2000,
showConfirmButton: false,
});
} catch (error) {
console.error("❌ Failed to delete application:", error);
// Show more specific error message
let errorMessage = "Failed to delete application. ";
if (error.data?.message) {
errorMessage += error.data.message;
} else if (error.statusCode === 404) {
errorMessage += "Application not found.";
} else if (error.statusCode === 401) {
errorMessage += "You are not authorized to delete this application.";
} else {
errorMessage += "Please try again.";
}
// Use SweetAlert instead of alert
const { $swal } = useNuxtApp();
$swal.fire({
icon: "error",
title: "Error",
text: errorMessage,
});
} finally {
isDeleting.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Never'
return new Date(dateString).toLocaleDateString() + ' ' + new Date(dateString).toLocaleTimeString()
}
if (!dateString) return "Never";
return (
new Date(dateString).toLocaleDateString() +
" " +
new Date(dateString).toLocaleTimeString()
);
};
const getStatusVariant = (status) => {
switch (status) {
case 'active': return 'success'
case 'development': return 'warning'
case 'inactive': return 'secondary'
default: return 'secondary'
case "active":
return "success";
case "development":
return "warning";
case "inactive":
return "secondary";
default:
return "secondary";
}
}
};
// Check if an application is protected (cannot be deleted)
const isProtectedApplication = (appName) => {
// List of protected applications that should not be deleted
const protectedApps = [
"CORRAD RBAC", // Main SSO application
"corrad-rbac",
"Authentik",
"authentik",
"CORRAD RBAC test2", // From the screenshot
];
// Case-insensitive check
return protectedApps.some(
(name) => appName && appName.toLowerCase().includes(name.toLowerCase())
);
};
// Initialize
onMounted(() => {
// Load initial data
})
fetchApplications();
});
</script>
<template>
@ -153,8 +253,12 @@ onMounted(() => {
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Applications</h1>
<p class="text-gray-600 dark:text-gray-400">Manage applications integrated with Authentik</p>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Applications
</h1>
<p class="text-gray-600 dark:text-gray-400">
Manage applications integrated with Authentik
</p>
</div>
<div class="flex space-x-3">
<rs-button @click="navigateTo('/applications/create')">
@ -171,13 +275,22 @@ onMounted(() => {
<template #body>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<Icon name="ph:app-window" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div
class="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center"
>
<Icon
name="ph:app-window"
class="w-5 h-5 text-blue-600 dark:text-blue-400"
/>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Applications</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.totalApps }}</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Total Applications
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ stats.totalApps }}
</p>
</div>
</div>
</template>
@ -187,13 +300,22 @@ onMounted(() => {
<template #body>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<Icon name="ph:check-circle" class="w-5 h-5 text-green-600 dark:text-green-400" />
<div
class="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center"
>
<Icon
name="ph:check-circle"
class="w-5 h-5 text-green-600 dark:text-green-400"
/>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Active Applications</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.activeApps }}</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Active Applications
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ stats.activeApps }}
</p>
</div>
</div>
</template>
@ -203,64 +325,35 @@ onMounted(() => {
<template #body>
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center">
<Icon name="ph:users" class="w-5 h-5 text-orange-600 dark:text-orange-400" />
<div
class="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-lg flex items-center justify-center"
>
<Icon
name="ph:users"
class="w-5 h-5 text-orange-600 dark:text-orange-400"
/>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total App Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.totalUsers }}</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">
Total App Users
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ stats.totalUsers }}
</p>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Filters -->
<rs-card class="mb-6">
<template #body>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-2">
<FormKit
v-model="searchQuery"
type="search"
placeholder="Search applications..."
:classes="{ outer: 'mb-0' }"
/>
</div>
<div>
<FormKit
v-model="selectedStatus"
type="select"
:options="[
{ value: 'all', label: 'All Statuses' },
{ value: 'active', label: 'Active' },
{ value: 'development', label: 'Development' },
{ value: 'inactive', label: 'Inactive' }
]"
:classes="{ outer: 'mb-0' }"
/>
</div>
<div>
<FormKit
v-model="selectedProvider"
type="select"
:options="[
{ value: 'all', label: 'All Providers' },
...providers
]"
:classes="{ outer: 'mb-0' }"
/>
</div>
</div>
</template>
</rs-card>
<!-- Applications Table -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">All Applications</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
All Applications
</h3>
<rs-badge variant="info">{{ stats.totalApps }} applications</rs-badge>
</div>
</template>
@ -269,38 +362,40 @@ onMounted(() => {
:field="['name', 'status', 'provider', 'users', 'created', 'actions']"
:data="filteredApplications"
:advanced="true"
:options="{
variant: 'default',
striped: false,
bordered: false,
hover: true
}"
:optionsAdvanced="{
sortable: true,
outsideBorder: false
}"
:pageSize="10"
:loading="isLoading"
>
<!-- Name Column -->
<template #name="{ value }">
<div class="flex items-center" v-if="value">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<span class="text-sm font-medium text-white">{{ value.name ? value.name.charAt(0).toUpperCase() : '?' }}</span>
<div
class="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center"
>
<span class="text-sm font-medium text-white">{{
value.name ? value.name.charAt(0).toUpperCase() : "?"
}}</span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ value.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ value.description || 'No description' }}</div>
<div
class="text-sm font-medium text-gray-900 dark:text-white hover:text-primary cursor-pointer"
@click="navigateTo(`/applications/${value.id}`)"
>
{{ value.name }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ value.description || "No description" }}
</div>
</div>
</div>
</template>
<!-- Status Column -->
<template #status="{ value }">
<rs-badge :variant="getStatusVariant(value?.status || 'inactive')" v-if="value">
{{ value.status || 'Unknown' }}
<rs-badge
:variant="getStatusVariant(value?.status || 'inactive')"
v-if="value"
>
{{ value.status || "Unknown" }}
</rs-badge>
</template>
@ -308,42 +403,70 @@ onMounted(() => {
<template #provider="{ value }">
<div class="flex items-center" v-if="value && value.provider">
<Icon
:name="value.provider === 'OAuth2/OIDC' ? 'ph:key' : 'ph:shield'"
:name="
value.provider === 'OAuth2/OIDC' ? 'ph:key' : 'ph:shield'
"
class="w-4 h-4 mr-2 text-gray-400"
/>
<span class="text-sm text-gray-900 dark:text-white">{{ value.provider }}</span>
<span class="text-sm text-gray-900 dark:text-white">{{
value.provider
}}</span>
</div>
</template>
<!-- Users Column -->
<template #users="{ value }">
<div class="text-sm" v-if="value">
<div class="text-gray-900 dark:text-white">{{ value.userCount || 0 }} users</div>
<div class="text-gray-500 dark:text-gray-400">{{ value.roleCount || 0 }} roles</div>
<div class="text-gray-900 dark:text-white">
{{ value.userCount || 0 }} users
</div>
<div class="text-gray-500 dark:text-gray-400">
{{ value.roleCount || 0 }} roles
</div>
</div>
</template>
<!-- Created Column -->
<template #created="{ value }">
<div class="text-sm" v-if="value">
<div class="text-gray-900 dark:text-white">{{ formatDate(value.createdAt) }}</div>
<div class="text-gray-500 dark:text-gray-400">{{ value.createdBy || 'System' }}</div>
<div class="text-gray-900 dark:text-white">
{{ formatDate(value.createdAt) }}
</div>
<div class="text-gray-500 dark:text-gray-400">
{{ value.createdBy || "System" }}
</div>
</div>
</template>
<!-- Actions Column -->
<template #actions="{ value }">
<div class="flex items-center space-x-2" v-if="value && value.id">
<button class="text-primary hover:text-primary/80" @click="navigateTo(`/applications/${value.id}`)">
<div
class="flex items-center space-x-2"
v-if="value && value.id && !isProtectedApplication(value.name)"
>
<button
class="text-primary hover:text-primary/80 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
@click="navigateTo(`/applications/${value.id}`)"
title="View Application"
>
<Icon name="ph:eye" class="w-4 h-4" />
</button>
<button class="text-primary hover:text-primary/80" @click="navigateTo(`/applications/${value.id}/edit`)">
<button
class="text-primary hover:text-primary/80 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
@click="navigateTo(`/applications/${value.id}/edit`)"
title="Edit Application"
>
<Icon name="ph:pencil" class="w-4 h-4" />
</button>
<button class="text-red-600 hover:text-red-800" @click="deleteApplication(value.id)">
<button
class="text-red-600 hover:text-red-800 p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
@click="deleteApplication(value.id)"
title="Delete Application"
>
<Icon name="ph:trash" class="w-4 h-4" />
</button>
</div>
<div class="flex items-center space-x-2" v-else>-</div>
</template>
</RsTable>
</template>

View File

@ -101,15 +101,27 @@ const submitForm = async () => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Success handling
alert('User created successfully!')
// Success handling using SweetAlert
const { $swal } = useNuxtApp();
$swal.fire({
icon: 'success',
title: 'Success',
text: 'User created successfully!',
timer: 2000,
showConfirmButton: false
})
// Reset form or redirect
// resetForm()
} catch (error) {
console.error('Error creating user:', error)
alert('Error creating user. Please try again.')
const { $swal } = useNuxtApp();
$swal.fire({
icon: 'error',
title: 'Error',
text: 'Error creating user. Please try again.'
})
} finally {
isSubmitting.value = false
}

View File

@ -218,17 +218,6 @@ onMounted(() => {
:field="['group', 'members', 'status', 'parentGroup', 'actions']"
:data="groups"
:advanced="true"
:options="{
variant: 'default',
striped: false,
bordered: false,
hover: true
}"
:optionsAdvanced="{
sortable: true,
outsideBorder: false
}"
:pageSize="10"
>
<!-- Group Column -->
<template #group="{ value }">

View File

@ -244,17 +244,6 @@ onMounted(() => {
:field="['role', 'application', 'status', 'users', 'actions']"
:data="roles"
:advanced="true"
:options="{
variant: 'default',
striped: false,
bordered: false,
hover: true
}"
:optionsAdvanced="{
sortable: true,
outsideBorder: false
}"
:pageSize="10"
>
<!-- Role Column -->
<template #role="{ value }">

223
pages/test-api.vue Normal file
View File

@ -0,0 +1,223 @@
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-4xl mx-auto px-4">
<h1 class="text-3xl font-bold text-gray-900 mb-8">API Testing Page</h1>
<!-- Test Application Creation -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Test Application Creation</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<button
@click="testCreateApp('oauth2')"
:disabled="loading"
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Test OAuth2 App
</button>
<button
@click="testCreateApp('ldap')"
:disabled="loading"
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Test LDAP App
</button>
<button
@click="testCreateApp('saml')"
:disabled="loading"
class="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Test SAML App
</button>
<button
@click="testCreateApp('proxy')"
:disabled="loading"
class="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Test Proxy App
</button>
</div>
<div class="mb-4">
<button
@click="testListApps"
:disabled="loading"
class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded disabled:opacity-50 mr-2"
>
List Applications
</button>
<button
@click="testAuthentikConnection"
:disabled="loading"
class="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Test Authentik Connection
</button>
</div>
<div v-if="loading" class="text-blue-600 mb-4">
{{ loadingMessage }}
</div>
</div>
<!-- Results Display -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Results</h2>
<div v-if="result" class="mb-4">
<div class="flex items-center mb-2">
<span class="text-sm font-medium text-gray-500 mr-2">Status:</span>
<span :class="result.success ? 'text-green-600' : 'text-red-600'" class="font-medium">
{{ result.success ? '✅ Success' : '❌ Error' }}
</span>
</div>
<div class="bg-gray-50 rounded p-4 overflow-auto">
<pre class="text-sm">{{ JSON.stringify(result.data, null, 2) }}</pre>
</div>
</div>
<div v-if="!result" class="text-gray-500 text-center py-8">
Click a test button above to see results here
</div>
<button
v-if="result"
@click="result = null"
class="mt-4 bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded"
>
Clear Results
</button>
</div>
</div>
</div>
</template>
<script setup>
// State
const loading = ref(false)
const loadingMessage = ref('')
const result = ref(null)
// Test functions
const testCreateApp = async (providerType) => {
loading.value = true
loadingMessage.value = `Creating ${providerType.toUpperCase()} application...`
try {
const timestamp = Date.now()
const testApp = {
name: `Test ${providerType.toUpperCase()} ${timestamp}`,
slug: `test-${providerType}-${timestamp}`,
meta_description: `Test ${providerType} application for debugging`,
meta_launch_url: 'http://localhost:5000/test',
meta_publisher: 'IT Department - Test',
setupType: 'web-app',
providerType: providerType
}
console.log(`🧪 Testing ${providerType} application creation:`, testApp)
const response = await $fetch('/api/applications', {
method: 'POST',
body: testApp
})
result.value = {
success: true,
data: {
message: `${providerType.toUpperCase()} application created successfully!`,
application: response,
requestData: testApp
}
}
} catch (error) {
console.error(`${providerType} test failed:`, error)
result.value = {
success: false,
data: {
error: error.message,
statusCode: error.statusCode,
details: error.data,
requestData: testApp
}
}
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const testListApps = async () => {
loading.value = true
loadingMessage.value = 'Fetching applications list...'
try {
const response = await $fetch('/api/applications')
result.value = {
success: true,
data: {
message: 'Applications fetched successfully!',
count: response.results?.length || 0,
applications: response.results || []
}
}
} catch (error) {
console.error('❌ List apps test failed:', error)
result.value = {
success: false,
data: {
error: error.message,
statusCode: error.statusCode,
details: error.data
}
}
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const testAuthentikConnection = async () => {
loading.value = true
loadingMessage.value = 'Testing Authentik connection...'
try {
const response = await $fetch('/api/test-authentik')
result.value = {
success: true,
data: {
message: 'Authentik connection test successful!',
connectionTest: response
}
}
} catch (error) {
console.error('❌ Authentik connection test failed:', error)
result.value = {
success: false,
data: {
error: error.message,
statusCode: error.statusCode,
details: error.data
}
}
} finally {
loading.value = false
loadingMessage.value = ''
}
}
// Page metadata
useHead({
title: 'API Testing - CorradAF RBAC'
})
</script>

View File

@ -164,17 +164,6 @@ onMounted(() => {
:field="['user', 'department', 'status', 'lastLogin', 'actions']"
:data="users"
:advanced="true"
:options="{
variant: 'default',
striped: false,
bordered: false,
hover: true
}"
:optionsAdvanced="{
sortable: true,
outsideBorder: false
}"
:pageSize="10"
>
<!-- User Column -->
<template #user="{ value }">

View File

@ -1,25 +1,24 @@
import { authentikFetch } from '../../utils/authentik';
import { authentikFetch, clearAuthentikCache } from '../../utils/authentik';
import { requireAuth } from '../../utils/auth';
// /api/applications/[id] - Handle GET, PUT, DELETE for specific application
// Note: [id] is actually the application slug for consistency with Authentik API
export default defineEventHandler(async (event) => {
const method = getMethod(event);
const id = getRouterParam(event, 'id');
const slug = getRouterParam(event, 'id'); // This is actually a slug
// Require authentication
await requireAuth(event);
if (!id) {
if (!slug) {
throw createError({
statusCode: 400,
message: 'Application ID is required'
message: 'Application slug is required'
});
}
switch (method) {
case 'GET':
// Make GET public for testing - no auth required
try {
const application = await authentikFetch(`/core/applications/${id}/`);
const application = await authentikFetch(`/core/applications/${slug}/`);
return application;
} catch (error) {
throw createError({
@ -29,38 +28,85 @@ export default defineEventHandler(async (event) => {
}
case 'PUT':
// Require authentication for updating applications
await requireAuth(event);
try {
const body = await readBody(event);
console.log('🔄 Updating application:', slug, body);
const application = await authentikFetch(`/core/applications/${id}/`, {
method: 'PUT',
body: {
// Prepare the update payload with all fields from frontend
const updatePayload = {
name: body.name,
slug: body.slug,
meta_description: body.description,
meta_publisher: 'CorradAF RBAC'
}
meta_description: body.meta_description || body.description,
meta_publisher: body.meta_publisher || 'CorradAF RBAC',
meta_launch_url: body.meta_launch_url || body.launchUrl
};
console.log('📦 Update payload:', updatePayload);
const application = await authentikFetch(`/core/applications/${slug}/`, {
method: 'PATCH', // Use PATCH instead of PUT to update only specified fields
body: updatePayload
});
return application;
console.log('✅ Application updated successfully:', application.name);
// Clear Authentik cache to ensure changes take effect immediately
try {
await clearAuthentikCache();
console.log('✅ Cache cleared after application update');
} catch (cacheError) {
console.warn('⚠️ Cache clearing failed but application was updated:', cacheError.message);
// Continue even if cache clearing fails
}
// Add success message to the response
return {
...application,
message: 'Application updated successfully'
};
} catch (error) {
console.error('❌ Update failed:', error);
throw createError({
statusCode: error.statusCode || 500,
message: error.message
message: error.message || 'Failed to update application'
});
}
case 'DELETE':
// Require authentication for deleting applications
await requireAuth(event);
try {
await authentikFetch(`/core/applications/${id}/`, {
console.log(`🗑️ Attempting to delete application with slug: ${slug}`);
// Direct DELETE using slug (no extra GET request needed)
await authentikFetch(`/core/applications/${slug}/`, {
method: 'DELETE'
});
return { message: 'Application deleted successfully' };
console.log(`✅ Successfully deleted application ${slug}`);
// Clear Authentik cache to ensure changes take effect immediately
try {
await clearAuthentikCache();
console.log('✅ Cache cleared after application deletion');
} catch (cacheError) {
console.warn('⚠️ Cache clearing failed but application was deleted:', cacheError.message);
// Continue even if cache clearing fails
}
return {
success: true,
message: 'Application deleted successfully'
};
} catch (error) {
console.error(`❌ Delete failed for application ${slug}:`, error);
throw createError({
statusCode: error.statusCode || 500,
message: error.message
message: error.message || 'Failed to delete application'
});
}

View File

@ -1,56 +1,218 @@
import { authentikFetch, createAuthentikApplication, createAuthentikProvider, linkProviderToApplication } from '../../utils/authentik';
import { authentikFetch, clearAuthentikCache } from '../../utils/authentik';
import { requireAuth } from '../../utils/auth';
// /api/applications - Handle GET and POST
// Simplified /api/applications endpoint
export default defineEventHandler(async (event) => {
const method = getMethod(event);
// Require authentication for all application endpoints
await requireAuth(event);
switch (method) {
case 'GET':
// Public endpoint for listing applications
try {
const applications = await authentikFetch('/core/applications/');
return applications;
const response = await authentikFetch('/core/applications/');
return response;
} catch (error) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message
});
console.error('❌ Failed to fetch applications:', error.message);
throw error;
}
case 'POST':
// TODO: Add authentication later - for now make it public for testing
await requireAuth(event);
try {
const body = await readBody(event);
console.log(`🔨 Creating application: ${body.name} (${body.providerType})`);
// Create application in Authentik
const application = await createAuthentikApplication({
// Simplified application creation - just the essentials
// Create application first
const applicationData = {
name: body.name,
slug: body.slug || body.name.toLowerCase().replace(/\s+/g, '-'),
meta_description: body.description,
meta_publisher: 'CorradAF RBAC'
});
slug: body.slug || body.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
meta_description: body.meta_description || body.description,
meta_publisher: body.meta_publisher || 'CorradAF RBAC',
meta_launch_url: body.meta_launch_url || body.launchUrl
};
// Create OAuth2 provider if web application
if (body.type === 'web-app') {
const provider = await createAuthentikProvider({
name: `${body.name} OAuth2`,
client_type: 'confidential',
redirect_uris: body.redirectUris,
authorization_flow: body.authorizationFlow || 'default-authentication-flow'
let application;
try {
application = await authentikFetch('/core/applications/', {
method: 'POST',
body: applicationData
});
// Link provider to application
await linkProviderToApplication(application.pk, provider.pk);
console.log('✅ Application created successfully');
} catch (appError) {
console.error('❌ Application creation failed:', appError.message);
if (appError.data) {
console.error('Details:', JSON.stringify(appError.data, null, 2));
}
throw appError;
}
return application;
} catch (error) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message
// Create provider based on type (simplified presets)
if (body.providerType && application.pk) {
try {
console.log(`🔨 Creating ${body.providerType.toUpperCase()} provider...`);
// Get required flows for all providers
const flows = await authentikFetch('/flows/instances/');
// Find required flows
const authFlows = flows.results.filter(flow => flow.designation === 'authentication');
const invalidationFlows = flows.results.filter(flow => flow.designation === 'invalidation');
const defaultAuthFlow = authFlows.find(flow => flow.slug === 'default-authentication-flow') || authFlows[0];
const defaultInvalidationFlow = invalidationFlows.find(flow => flow.slug === 'default-invalidation-flow') || invalidationFlows[0];
if (!defaultAuthFlow) {
throw new Error('No authentication flow found - required for all providers');
}
let provider = null;
switch (body.providerType) {
case 'oauth2':
// OAuth2/OIDC provider preset
const oauth2Data = {
name: `${body.name} OAuth2`,
client_type: 'confidential',
authorization_flow: defaultAuthFlow.pk,
invalidation_flow: defaultInvalidationFlow?.pk,
redirect_uris: [
{
matching_mode: 'strict',
url: `${body.meta_launch_url || body.launchUrl}/auth/callback`
}
]
};
try {
provider = await authentikFetch('/providers/oauth2/', {
method: 'POST',
body: oauth2Data
});
console.log('✅ OAuth2 provider created');
} catch (oauth2Error) {
console.error('❌ OAuth2 provider failed:', oauth2Error.message);
if (oauth2Error.data) {
console.error('Details:', JSON.stringify(oauth2Error.data, null, 2));
}
throw oauth2Error;
}
break;
case 'saml':
// SAML provider preset
const samlData = {
name: `${body.name} SAML`,
acs_url: `${body.meta_launch_url || body.launchUrl}/saml/acs`,
audience: body.slug,
issuer: `corradaf-${body.slug}`,
sp_binding: 'post',
authorization_flow: defaultAuthFlow.pk,
invalidation_flow: defaultInvalidationFlow?.pk
};
try {
provider = await authentikFetch('/providers/saml/', {
method: 'POST',
body: samlData
});
console.log('✅ SAML provider created');
} catch (samlError) {
console.error('❌ SAML provider failed:', samlError.message);
if (samlError.data) {
console.error('Details:', JSON.stringify(samlError.data, null, 2));
}
throw samlError;
}
break;
case 'ldap':
// LDAP provider preset
const ldapData = {
name: `${body.name} LDAP`,
base_dn: 'dc=ldap,dc=goauthentik,dc=io',
authorization_flow: defaultAuthFlow.pk,
invalidation_flow: defaultInvalidationFlow?.pk
};
try {
provider = await authentikFetch('/providers/ldap/', {
method: 'POST',
body: ldapData
});
console.log('✅ LDAP provider created');
} catch (ldapError) {
console.error('❌ LDAP provider failed:', ldapError.message);
if (ldapError.data) {
console.error('Details:', JSON.stringify(ldapError.data, null, 2));
}
throw ldapError;
}
break;
case 'proxy':
// Proxy provider preset
const proxyData = {
name: `${body.name} Proxy`,
external_host: body.meta_launch_url || body.launchUrl,
internal_host: body.meta_launch_url || body.launchUrl,
authorization_flow: defaultAuthFlow.pk,
invalidation_flow: defaultInvalidationFlow?.pk
};
try {
provider = await authentikFetch('/providers/proxy/', {
method: 'POST',
body: proxyData
});
console.log('✅ Proxy provider created');
} catch (proxyError) {
console.error('❌ Proxy provider failed:', proxyError.message);
if (proxyError.data) {
console.error('Details:', JSON.stringify(proxyError.data, null, 2));
}
throw proxyError;
}
break;
}
// Link provider to application
if (provider && provider.pk) {
await authentikFetch(`/core/applications/${application.slug}/`, {
method: 'PATCH',
body: {
provider: provider.pk
}
});
console.log(`${body.providerType.toUpperCase()} provider linked to application`);
} else {
console.warn('⚠️ Provider was not created properly');
}
} catch (providerError) {
console.warn('⚠️ Provider creation failed, but application was created:', providerError.message);
// Don't fail the entire operation if provider creation fails
}
}
// Clear Authentik cache to ensure changes take effect immediately
try {
await clearAuthentikCache();
console.log('✅ Cache cleared after application creation');
} catch (cacheError) {
console.warn('⚠️ Cache clearing failed but application was created:', cacheError.message);
// Continue even if cache clearing fails
}
return {
...application,
message: 'Application created successfully'
};
} catch (error) {
console.error('❌ Failed to create application:', error.message);
throw error;
}
default:

View File

@ -0,0 +1,46 @@
// Simple test endpoint to verify Authentik connection
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
try {
console.log('🧪 Testing Authentik connection...');
console.log('🔗 Authentik URL:', config.public.authentikUrl);
console.log('🔑 API Token exists:', !!config.authentik?.apiToken);
console.log('🔑 API Token length:', config.authentik?.apiToken?.length || 0);
// Test basic API access
const response = await $fetch(`${config.public.authentikUrl}/api/v3/core/applications/`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.authentik.apiToken}`
}
});
return {
success: true,
message: 'Authentik connection successful!',
data: {
authentikUrl: config.public.authentikUrl,
applicationsCount: response.results?.length || 0,
tokenExists: !!config.authentik?.apiToken
}
};
} catch (error) {
console.error('❌ Authentik connection test failed:', error);
return {
success: false,
error: {
status: error.response?.status,
message: error.message,
details: error.response?.statusText
},
debugging: {
authentikUrl: config.public.authentikUrl,
tokenExists: !!config.authentik?.apiToken,
tokenLength: config.authentik?.apiToken?.length || 0
}
};
}
});

View File

@ -1,38 +1,63 @@
// Authentication utilities for API routes
// Authentication utilities for API routes - Updated for cookie-based auth
export const requireAuth = async (event) => {
const config = useRuntimeConfig();
const authHeader = getHeader(event, 'Authorization');
// Check for auth_token in cookies (Authentik sends via cookies)
const authToken = getCookie(event, 'auth_token');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
if (!authToken) {
console.error('❌ No auth_token cookie found');
throw createError({
statusCode: 401,
message: 'No token provided'
message: 'No authentication token provided'
});
}
// Extract the token without the 'Bearer ' prefix
const token = authHeader.split(' ')[1];
try {
// Verify token with Authentik
const response = await $fetch(`${config.public.authentikUrl}/api/v3/core/tokens/verify/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
// Decode JWT token locally (no API call needed since token contains all user info)
console.log('🔐 Decoding JWT token...');
// Simple JWT decode (split and base64 decode the payload)
const tokenParts = authToken.split('.');
if (tokenParts.length !== 3) {
throw new Error('Invalid JWT format');
}
});
// Decode the payload (second part of JWT)
const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error('Token has expired');
}
// Extract user information from JWT payload
const userInfo = {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified,
name: payload.name,
given_name: payload.given_name,
preferred_username: payload.preferred_username,
nickname: payload.nickname,
groups: payload.groups || [],
uid: payload.uid
};
console.log('✅ Authentication successful for user:', userInfo.preferred_username);
// Add user info to event context
event.context.auth = response;
return response;
event.context.auth = userInfo;
event.context.authToken = authToken;
return userInfo;
} catch (error) {
console.error('Token verification error:', error);
console.error('❌ Token verification failed:', error.message);
throw createError({
statusCode: 401,
message: 'Invalid or expired token'
message: 'Invalid or expired authentication token'
});
}
};

View File

@ -1,8 +1,17 @@
// Authentik API utilities
// Simplified Authentik API utilities
export const authentikFetch = async (endpoint, options = {}) => {
const config = useRuntimeConfig();
const AUTHENTIK_BASE_URL = `${config.public.authentikUrl}/api/v3`;
// Debug: Check if token exists
if (!config.authentik?.apiToken) {
console.error('❌ AUTHENTIK_API_TOKEN is missing from environment variables');
throw createError({
statusCode: 500,
message: 'Authentik API token not configured. Please set AUTHENTIK_API_TOKEN in your .env file'
});
}
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
@ -11,9 +20,6 @@ export const authentikFetch = async (endpoint, options = {}) => {
};
try {
// Log the request for debugging
console.log(`Authentik API Request: ${AUTHENTIK_BASE_URL}${endpoint}`);
const response = await $fetch(`${AUTHENTIK_BASE_URL}${endpoint}`, {
...defaultOptions,
...options,
@ -22,16 +28,38 @@ export const authentikFetch = async (endpoint, options = {}) => {
...options.headers
}
});
return response;
} catch (error) {
console.error(`Authentik API Error for ${endpoint}:`, error);
// Only log errors that need attention
console.error(`❌ Authentik API Error: ${endpoint} - ${error.message}`);
// More specific error messages
if (error.response?.status === 403) {
throw createError({
statusCode: 403,
message: 'Authentik API token does not have sufficient permissions. Please check your token configuration in Authentik admin.',
data: error.data || error.response?._data
});
}
if (error.response?.status === 401) {
throw createError({
statusCode: 401,
message: 'Authentik API token is invalid or expired. Please check AUTHENTIK_API_TOKEN in your .env file.',
data: error.data || error.response?._data
});
}
throw createError({
statusCode: error.response?.status || 500,
message: error.message || 'Failed to communicate with Authentik API'
message: error.message || 'Failed to communicate with Authentik API',
data: error.data || error.response?._data
});
}
};
// Only keep essential helper functions - no over-engineering
export const getAuthentikUser = async (userId) => {
return await authentikFetch(`/core/users/${userId}/`);
};
@ -40,35 +68,17 @@ export const getAuthentikGroups = async () => {
return await authentikFetch('/core/groups/');
};
export const createAuthentikApplication = async (applicationData) => {
return await authentikFetch('/core/applications/', {
method: 'POST',
body: applicationData
// Clear Authentik policy cache
export const clearAuthentikCache = async () => {
try {
console.log('🧹 Clearing Authentik policy cache...');
const response = await authentikFetch('/policies/all/cache_clear/', {
method: 'POST'
});
};
export const createAuthentikProvider = async (providerData) => {
return await authentikFetch('/providers/oauth2/', {
method: 'POST',
body: providerData
});
};
export const linkProviderToApplication = async (applicationId, providerId) => {
return await authentikFetch(`/core/applications/${applicationId}/`, {
method: 'PATCH',
body: {
provider: providerId
console.log('✅ Authentik cache cleared successfully');
return { success: true, message: 'Cache cleared successfully' };
} catch (error) {
console.error('❌ Failed to clear Authentik cache:', error);
throw error;
}
});
};
// Add a utility function to verify tokens
export const verifyToken = async (token) => {
return await authentikFetch('/core/tokens/verify/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
};