Md Afiq Iskandar ef5526baf1 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.
2025-06-17 11:53:15 +08:00

480 lines
15 KiB
Vue

<script setup>
definePageMeta({
title: "Applications",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{ name: "Dashboard", path: "/dashboard" },
{ name: "Applications", path: "/applications", type: "current" },
],
});
import { ref, onMounted, computed } from "vue";
// 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;
}
};
// Computed properties
const filteredApplications = computed(() => {
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))
);
}
if (selectedStatus.value !== "all") {
filtered = filtered.filter((app) => app.status === selectedStatus.value);
}
if (selectedProvider.value !== "all") {
filtered = filtered.filter(
(app) => app.provider === selectedProvider.value
);
}
return filtered;
});
const stats = computed(() => ({
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,
}));
});
// Methods
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;
}
// 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()
);
};
const getStatusVariant = (status) => {
switch (status) {
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(() => {
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>
</div>
<div class="flex space-x-3">
<rs-button @click="navigateTo('/applications/create')">
<Icon name="ph:plus" class="w-4 h-4 mr-2" />
Create Application
</rs-button>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<rs-card>
<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>
</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>
</div>
</div>
</template>
</rs-card>
<rs-card>
<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>
</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>
</div>
</div>
</template>
</rs-card>
<rs-card>
<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>
</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>
</div>
</div>
</template>
</rs-card>
</div>
<!-- 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>
<rs-badge variant="info">{{ stats.totalApps }} applications</rs-badge>
</div>
</template>
<template #body>
<RsTable
:field="['name', 'status', 'provider', 'users', 'created', 'actions']"
:data="filteredApplications"
:advanced="true"
>
<!-- 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>
</div>
<div class="ml-4">
<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>
</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"
/>
<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>
</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>
</template>
<!-- Actions Column -->
<template #actions="{ value }">
<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 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 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>
</rs-card>
</div>
</template>
<style scoped>
/* Component specific styles */
</style>