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>

File diff suppressed because it is too large Load Diff

View File

@ -5,156 +5,260 @@ 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...";
}
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;
}
])
const isLoading = ref(false)
const isSyncing = ref(false)
const searchQuery = ref('')
const selectedStatus = ref('all')
const selectedProvider = ref('all')
};
// 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 =>
(app.name && app.name.toLowerCase().includes(query)) ||
(app.description && app.description.toLowerCase().includes(query)) ||
(app.publisher && app.publisher.toLowerCase().includes(query))
)
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)
if (index > -1) {
applications.value.splice(index, 1)
console.log('Application deleted successfully')
// 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");
// 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>
<div>
<LayoutsBreadcrumb />
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">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,81 +362,111 @@ 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>
<!-- Provider Column -->
<template #provider="{ value }">
<div class="flex items-center" v-if="value && value.provider">
<Icon
:name="value.provider === 'OAuth2/OIDC' ? 'ph:key' : 'ph:shield'"
class="w-4 h-4 mr-2 text-gray-400"
<Icon
: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>
@ -353,4 +476,4 @@ onMounted(() => {
<style scoped>
/* Component specific styles */
</style>
</style>

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: {
name: body.name,
slug: body.slug,
meta_description: body.description,
meta_publisher: 'CorradAF RBAC'
}
// Prepare the update payload with all fields from frontend
const updatePayload = {
name: body.name,
slug: body.slug,
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;
// 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) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message
});
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
});
};
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
}
});
};
// Add a utility function to verify tokens
export const verifyToken = async (token) => {
return await authentikFetch('/core/tokens/verify/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
// 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'
});
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;
}
};