- 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.
480 lines
15 KiB
Vue
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>
|