mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
feat: Enhance user management and dashboard components
- Updated user management templates to improve user experience with a new layout and success/error message handling. - Implemented password hashing for user creation and management, enhancing security. - Refactored dashboard components to include better data presentation and responsiveness. - Added dark mode support for various components, ensuring a consistent user interface across themes. - Improved toast notification animations for better user feedback during actions.
This commit is contained in:
@@ -262,7 +262,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800';
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
@@ -301,15 +301,25 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
toast.remove();
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'transition-opacity', 'duration-300');
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
|
||||
@@ -94,14 +94,20 @@ templ AdminRoles(ctx context.Context, data RolesData) {
|
||||
<div class="p-4 md:p-5 text-center">
|
||||
<i class="fas fa-exclamation-triangle mx-auto mb-4 text-red-400 text-3xl"></i>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete this role?</h3>
|
||||
<form method="POST" id="delete-form" action="">
|
||||
<button type="submit" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
|
||||
<div>
|
||||
<button
|
||||
id="confirm-delete-button"
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
|
||||
Yes, delete it
|
||||
</button>
|
||||
<button type="button" data-modal-hide="confirm-delete-modal" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
data-modal-hide="confirm-delete-modal"
|
||||
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
No, cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,14 +115,52 @@ templ AdminRoles(ctx context.Context, data RolesData) {
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let currentRoleId = null;
|
||||
const modal = document.getElementById('confirm-delete-modal');
|
||||
|
||||
// Handle delete modal
|
||||
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const roleId = this.getAttribute('data-role-id');
|
||||
document.getElementById('delete-form').action = `/admin/roles/${roleId}/delete`;
|
||||
currentRoleId = this.getAttribute('data-role-id');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle confirm delete button
|
||||
const confirmDeleteButton = document.getElementById('confirm-delete-button');
|
||||
confirmDeleteButton.addEventListener('click', function() {
|
||||
if (currentRoleId) {
|
||||
// Use fetch to send DELETE request
|
||||
fetch(`/admin/roles/${currentRoleId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Reload page on success
|
||||
window.location.reload();
|
||||
} else {
|
||||
// Handle error
|
||||
response.json().then(data => {
|
||||
alert('Error: ' + (data.error || 'Failed to delete role'));
|
||||
}).catch(() => {
|
||||
alert('An error occurred while deleting the role');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the role');
|
||||
})
|
||||
.finally(() => {
|
||||
// Hide modal
|
||||
const closeModalButton = document.querySelector('[data-modal-hide="confirm-delete-modal"]');
|
||||
if (closeModalButton) closeModalButton.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
+223
-201
@@ -43,214 +43,216 @@ func GetRcloneVersion() string {
|
||||
|
||||
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
@LayoutWithContext("Dashboard", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between mb-6 md:mb-8 gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Dashboard
|
||||
</h1>
|
||||
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Active Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
|
||||
</div>
|
||||
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Dashboard
|
||||
</h1>
|
||||
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Today -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Recent Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Recent Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
if len(data.RecentJobs) == 0 {
|
||||
<!-- Empty state with Flowbite styling -->
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create your first job
|
||||
</a>
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Active Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flow-root">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.RecentJobs {
|
||||
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
if job.Status == "completed" {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else if job.Status == "failed" {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
|
||||
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
{ getConfigNameForHistory(job, data.Configs) }
|
||||
</p>
|
||||
<div class="flex items-center mt-1">
|
||||
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Today -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Recent Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Recent Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
if len(data.RecentJobs) == 0 {
|
||||
<!-- Empty state with Flowbite styling -->
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create your first job
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flow-root">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.RecentJobs {
|
||||
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
if job.Status == "completed" {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else if job.Status == "failed" {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else {
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
|
||||
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
{ getConfigNameForHistory(job, data.Configs) }
|
||||
</p>
|
||||
<div class="flex items-center mt-1">
|
||||
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||
View
|
||||
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||
View
|
||||
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||
View all jobs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||
View all jobs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||
Create New Config
|
||||
</a>
|
||||
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||
Create New Job
|
||||
</a>
|
||||
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||
View Transfer History
|
||||
</a>
|
||||
|
||||
<!-- System Status Card with Flowbite design -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Running
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
|
||||
if data.RcloneVersion != "" {
|
||||
{ data.RcloneVersion }
|
||||
} else {
|
||||
Unknown
|
||||
}
|
||||
</span>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||
Create New Config
|
||||
</a>
|
||||
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||
Create New Job
|
||||
</a>
|
||||
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||
View Transfer History
|
||||
</a>
|
||||
|
||||
<!-- System Status Card with Flowbite design -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Running
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
|
||||
if data.RcloneVersion != "" {
|
||||
{ data.RcloneVersion }
|
||||
} else {
|
||||
Unknown
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+343
-273
@@ -341,17 +341,15 @@ templ FileMetadataListPartial(data FileMetadataListData) {
|
||||
// Update the main FileMetadataList template to use the partial template
|
||||
templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
@LayoutWithContext("File Metadata", ctx) {
|
||||
<div class="px-2 py-4 sm:px-4 w-full">
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
<div id="file-metadata-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
if data.Job != nil {
|
||||
Files for Job: { data.Job.Name }
|
||||
} else {
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
File Metadata
|
||||
</h1>
|
||||
File Metadata
|
||||
}
|
||||
</h1>
|
||||
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
@@ -425,6 +423,26 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('file-metadata-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('file-metadata-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -432,213 +450,239 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
// FileMetadataDetails renders detailed information about a file
|
||||
templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
|
||||
@LayoutWithContext("File Details", ctx) {
|
||||
<div class="px-2 py-4 sm:px-4 w-full">
|
||||
<div class="mb-6">
|
||||
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
{ data.File.FileName }
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||
</p>
|
||||
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
File Details: { data.File.FileName }
|
||||
</h1>
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- File Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Filename
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.FileName }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Size
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ formatFileSize(data.File.FileSize) }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Hash
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
if data.File.FileHash != "" {
|
||||
<span class="font-mono">{ data.File.FileHash }</span>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Status
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", getStatusBadgeClass(data.File.Status) }>
|
||||
{ data.File.Status }
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Original Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||
<span>{ data.File.OriginalPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Destination Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||
<span>{ data.File.DestinationPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Job
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ data.File.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Processed Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Creation Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Modification Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
{ data.File.FileName }
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- File Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Error
|
||||
Filename
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.FileName }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Size
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ formatFileSize(data.File.FileSize) }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Hash
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||
if data.File.FileHash != "" {
|
||||
<span class="font-mono">{ data.File.FileHash }</span>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Status
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", getStatusBadgeClass(data.File.Status) }>
|
||||
{ data.File.Status }
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Original Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||
<span>{ data.File.OriginalPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Created
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Updated
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Destination Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||
<span>{ data.File.DestinationPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Job
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ data.File.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Processed Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Creation Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Modification Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Error
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Created
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Updated
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete dialog -->
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
data.File.ID,
|
||||
data.File.FileName,
|
||||
"details",
|
||||
)
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list mr-2"></i> Back to Files
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||
</button>
|
||||
|
||||
<!-- Delete dialog -->
|
||||
@FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
data.File.ID,
|
||||
data.File.FileName,
|
||||
"details",
|
||||
)
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list mr-2"></i> Back to Files
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('file-details-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -646,82 +690,108 @@ templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
|
||||
// FileMetadataSearch renders an advanced search form for file metadata
|
||||
templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) {
|
||||
@LayoutWithContext("Search Files", ctx) {
|
||||
<div class="px-2 py-4 sm:px-4 w-full">
|
||||
<div class="mb-6">
|
||||
<a href="/files" class="inline-flex items-center text-blue-600 hover:underline dark:text-blue-500">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||
<form
|
||||
hx-get="/files/search/partial"
|
||||
hx-target="#search-results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-form-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
hx-boost="false"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Search Files
|
||||
</button>
|
||||
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
<div id="file-search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
|
||||
File Metadata Search
|
||||
</h1>
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||
<form
|
||||
hx-get="/files/search/partial"
|
||||
hx-target="#search-results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-form-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
hx-boost="false"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Search Files
|
||||
</button>
|
||||
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<h5 class="text-lg font-semibold text-gray-900 dark:text-white">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h5>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
|
||||
</span>
|
||||
</div>
|
||||
<div id="search-results-container" class="w-full">
|
||||
@FileMetadataSearchContent(data)
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<h5 class="text-lg font-semibold text-gray-900 dark:text-white">Search Results ({ strconv.FormatInt(data.TotalCount, 10) })</h5>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) }
|
||||
</span>
|
||||
</div>
|
||||
<div id="search-results-container" class="w-full">
|
||||
@FileMetadataSearchContent(data)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('file-search-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('file-search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
+100
-78
@@ -264,91 +264,113 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
templ History(ctx context.Context, data HistoryData) {
|
||||
@LayoutWithContext("Transfer History", ctx) {
|
||||
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Transfer History
|
||||
</h1>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Pagination Controls -->
|
||||
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div class="w-full sm:w-auto relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Transfer History
|
||||
</h1>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||
Search
|
||||
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
if data.SearchTerm != "" {
|
||||
</div>
|
||||
|
||||
<!-- Search and Pagination Controls -->
|
||||
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div class="w-full sm:w-auto relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-indicator="#clear-indicator"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
type="submit"
|
||||
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
<span id="clear-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||
Search
|
||||
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="history-content" class="mt-6">
|
||||
@HistoryContent(ctx, data)
|
||||
if data.SearchTerm != "" {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-indicator="#clear-indicator"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
<span id="clear-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="history-content" class="mt-6">
|
||||
@HistoryContent(ctx, data)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('history-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ templ NotificationDropdown(data NotificationsData) {
|
||||
<div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
|
||||
<i class={ GetNotificationIcon(notification.Type) }></i>
|
||||
</div>
|
||||
<div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
|
||||
<i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
|
||||
</div>
|
||||
// <div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
|
||||
// <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
|
||||
// </div>
|
||||
</div>
|
||||
<div class="pl-3 w-full">
|
||||
<div class="text-gray-500 font-normal text-sm mb-1.5 dark:text-gray-400">
|
||||
|
||||
+15
-13
@@ -28,9 +28,10 @@ type SettingsData struct {
|
||||
|
||||
templ Settings(ctx context.Context, data SettingsData) {
|
||||
@LayoutWithContext("Application Settings", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Application Settings
|
||||
</h1>
|
||||
@@ -67,16 +68,16 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
<i class="fas fa-bell mr-2"></i>Notifications
|
||||
</button>
|
||||
</li>
|
||||
<li class="mr-2" role="presentation">
|
||||
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false">
|
||||
<i class="fas fa-sliders-h mr-2"></i>General
|
||||
</button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false">
|
||||
<i class="fas fa-shield-alt mr-2"></i>Security
|
||||
</button>
|
||||
</li>
|
||||
// <li class="mr-2" role="presentation">
|
||||
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="general-tab" data-tabs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="false">
|
||||
// <i class="fas fa-sliders-h mr-2"></i>General
|
||||
// </button>
|
||||
// </li>
|
||||
// <li role="presentation">
|
||||
// <button class="inline-block p-4 border-b-2 border-transparent rounded-t-lg hover:border-gray-300 hover:text-gray-600 dark:hover:text-gray-300" id="security-tab" data-tabs-target="#security" type="button" role="tab" aria-controls="security" aria-selected="false">
|
||||
// <i class="fas fa-shield-alt mr-2"></i>Security
|
||||
// </button>
|
||||
// </li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -435,6 +436,7 @@ templ Settings(ctx context.Context, data SettingsData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@toggleNotificationFields()
|
||||
}
|
||||
|
||||
@@ -7,17 +7,26 @@ import (
|
||||
)
|
||||
|
||||
type UserManagementData struct {
|
||||
Users []db.User
|
||||
Roles []db.Role
|
||||
ActiveTab string // "list", "create", or "edit"
|
||||
EditUser *db.User
|
||||
UserRoles []db.Role
|
||||
ErrorMessage string
|
||||
Users []db.User
|
||||
Roles []db.Role
|
||||
ActiveTab string // "list", "create", or "edit"
|
||||
EditUser *db.User
|
||||
UserRoles []db.Role
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
}
|
||||
|
||||
templ UserManagement(ctx context.Context, data UserManagementData) {
|
||||
@LayoutWithContext("User Management", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
@UserManagementContent(data)
|
||||
}
|
||||
}
|
||||
|
||||
// UserManagementContent is a partial component for HTMX to replace only the content
|
||||
templ UserManagementContent(data UserManagementData) {
|
||||
// <div id="userManagementContainer" class="px-4 py-8 mx-auto max-w-screen-xl lg:px-6">
|
||||
<div id="userManagementContainer" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
@@ -25,38 +34,33 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
|
||||
User Management
|
||||
</h1>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
<button
|
||||
class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
hx-get="/admin/users/new"
|
||||
hx-target="#user-management-content"
|
||||
hx-get="/admin/users/new"
|
||||
hx-target="#userManagementContainer"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
|
||||
Add User
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-300 dark:focus:ring-gray-700 border border-gray-200 dark:border-gray-600"
|
||||
hx-get="/admin/users"
|
||||
hx-target="#user-management-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||
View All Users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
<div id="status-message" class="mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
|
||||
<div class="font-medium">{ data.SuccessMessage }</div>
|
||||
</div>
|
||||
} else {
|
||||
<div id="status-message" class="hidden mb-4 p-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert"></div>
|
||||
}
|
||||
if data.ErrorMessage != "" {
|
||||
<div id="error-message" class="mb-4 p-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
<div class="font-medium">{ data.ErrorMessage }</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Content -->
|
||||
<div id="user-management-content">
|
||||
<!-- Main Content Container -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 dark:bg-gray-800">
|
||||
<!-- Include the appropriate tab -->
|
||||
if data.ActiveTab == "list" || data.ActiveTab == "" {
|
||||
@userList(data.Users)
|
||||
} else if data.ActiveTab == "create" {
|
||||
@@ -65,7 +69,6 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
|
||||
@userForm(data.EditUser, false, data.Roles)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8">
|
||||
<div class="flex p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900 dark:text-blue-400" role="alert">
|
||||
@@ -77,7 +80,7 @@ templ UserManagement(ctx context.Context, data UserManagementData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// User listing component
|
||||
@@ -149,17 +152,16 @@ templ userList(users []db.User) {
|
||||
<button
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
hx-get={ "/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit" }
|
||||
hx-target="#user-management-content"
|
||||
hx-target="#userManagementContainer"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
hx-delete={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
hx-target="#user-management-content"
|
||||
hx-target="#userManagementContainer"
|
||||
>
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
|
||||
</button>
|
||||
@@ -193,15 +195,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
<form
|
||||
class="space-y-4 md:space-y-6"
|
||||
if isNew {
|
||||
hx-post="/admin/users"
|
||||
} else {
|
||||
hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
|
||||
}
|
||||
hx-target="#user-management-content"
|
||||
hx-target="#userManagementContainer"
|
||||
x-data="{
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
@@ -214,19 +215,18 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="user@example.com"
|
||||
if !isNew {
|
||||
value={ user.Email }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
if isNew {
|
||||
@@ -235,28 +235,27 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
New Password (leave blank to keep current)
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
x-model="password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
if isNew {
|
||||
required="true"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
x-model="confirmPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
if isNew {
|
||||
required="true"
|
||||
}
|
||||
@@ -265,15 +264,14 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="is_admin"
|
||||
name="is_admin"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
<input
|
||||
id="is_admin"
|
||||
name="is_admin"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
if !isNew && user.GetIsAdmin() {
|
||||
checked="true"
|
||||
}
|
||||
@@ -285,18 +283,16 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if !isNew {
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white mb-2">Account Status</h4>
|
||||
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="account_locked"
|
||||
name="account_locked"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
<input
|
||||
id="account_locked"
|
||||
name="account_locked"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
if user.GetAccountLocked() {
|
||||
checked="true"
|
||||
}
|
||||
@@ -309,7 +305,6 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if len(availableRoles) > 0 {
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white mb-2">Roles</h4>
|
||||
@@ -317,11 +312,11 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
for _, role := range availableRoles {
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
<input
|
||||
id={ "role_" + strconv.Itoa(int(role.ID)) }
|
||||
name="roles[]"
|
||||
name="roles[]"
|
||||
value={ strconv.Itoa(int(role.ID)) }
|
||||
type="checkbox"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
if !isNew && hasRole(user, role.ID) {
|
||||
checked="true"
|
||||
@@ -338,20 +333,19 @@ templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
|
||||
hx-get="/admin/users"
|
||||
hx-target="#user-management-content"
|
||||
hx-target="#userManagementContainer"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
|
||||
>
|
||||
@@ -376,4 +370,4 @@ func hasRole(user *db.User, roleID uint) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/starfleetcptn/gomft/internal/db/migrations"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -1681,3 +1682,28 @@ func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// User struct method to set password with secure hashing
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Validate password length
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters long")
|
||||
}
|
||||
|
||||
// Hash the password using bcrypt
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Store the hashed password
|
||||
u.PasswordHash = string(hashedPassword)
|
||||
u.LastPasswordChange = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -571,3 +571,707 @@ func getUserID(c *gin.Context) uint {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// HandleUsers renders the user management page
|
||||
func (h *Handlers) HandleUsers(c *gin.Context) {
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
|
||||
// Fetch users from the database
|
||||
var users []db.User
|
||||
if err := h.DB.Find(&users).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "list",
|
||||
ErrorMessage: "Failed to fetch users: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Preload roles for each user
|
||||
for i := range users {
|
||||
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get success message from flash if available
|
||||
successMsg := c.Query("success")
|
||||
|
||||
data := components.UserManagementData{
|
||||
Users: users,
|
||||
ActiveTab: "list",
|
||||
SuccessMessage: successMsg,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNewUser renders the new user creation page
|
||||
func (h *Handlers) HandleNewUser(c *gin.Context) {
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
|
||||
// Fetch available roles
|
||||
var roles []db.Role
|
||||
if err := h.DB.Find(&roles).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to fetch roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
Roles: roles,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateUser processes user creation
|
||||
func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
passwordConfirm := c.PostForm("password_confirm")
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
roleIDs := c.PostFormArray("roles[]")
|
||||
|
||||
// Validate inputs
|
||||
if email == "" || password == "" {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Email and password are required",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if password != passwordConfirm {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Passwords do not match",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
user := &db.User{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
// Set password (this would use a proper password hashing mechanism)
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to set password: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set admin status
|
||||
user.SetIsAdmin(isAdmin)
|
||||
|
||||
// Start transaction
|
||||
tx := h.DB.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Database error: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Save user
|
||||
if err := tx.Create(user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to create user: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add roles if not admin and roles are selected
|
||||
if !isAdmin && len(roleIDs) > 0 {
|
||||
for _, roleIDStr := range roleIDs {
|
||||
roleID, err := strconv.ParseUint(roleIDStr, 10, 32)
|
||||
if err != nil {
|
||||
continue // Skip invalid IDs
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
var role db.Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
continue // Skip if role doesn't exist
|
||||
}
|
||||
|
||||
// Add role to user
|
||||
if err := tx.Model(user).Association("Roles").Append(&role); err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to assign roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create audit log entry
|
||||
creatorID := getUserID(c)
|
||||
auditLog := &db.AuditLog{
|
||||
Action: "create",
|
||||
EntityType: "user",
|
||||
EntityID: user.ID,
|
||||
UserID: creatorID,
|
||||
Timestamp: time.Now(),
|
||||
Details: map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"is_admin": user.GetIsAdmin(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := tx.Create(auditLog).Error; err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to create audit log: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "create",
|
||||
ErrorMessage: "Failed to commit transaction: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For HTMX requests, render the user list with success message
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
// Fetch users from the database
|
||||
var users []db.User
|
||||
if err := h.DB.Find(&users).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "list",
|
||||
ErrorMessage: "Failed to fetch users after creation: " + err.Error(),
|
||||
}
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Preload roles for each user
|
||||
for i := range users {
|
||||
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
data := components.UserManagementData{
|
||||
Users: users,
|
||||
ActiveTab: "list",
|
||||
SuccessMessage: "User created successfully",
|
||||
}
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
// Redirect to user list with success message for regular requests
|
||||
c.Redirect(http.StatusFound, "/admin/users?success=User+created+successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEditUser renders the user edit page
|
||||
func (h *Handlers) HandleEditUser(c *gin.Context) {
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
userID := c.Param("id")
|
||||
|
||||
// Fetch user
|
||||
var user db.User
|
||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "list",
|
||||
ErrorMessage: "User not found",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user's roles
|
||||
if err := h.DB.Model(&user).Association("Roles").Find(&user.Roles); err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to fetch user roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch all available roles
|
||||
var roles []db.Role
|
||||
if err := h.DB.Find(&roles).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to fetch roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
Roles: roles,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateUser processes user updates
|
||||
func (h *Handlers) HandleUpdateUser(c *gin.Context) {
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
userID := c.Param("id")
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
passwordConfirm := c.PostForm("password_confirm")
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
accountLocked := c.PostForm("account_locked") == "on"
|
||||
roleIDs := c.PostFormArray("roles[]")
|
||||
|
||||
// Fetch existing user
|
||||
var user db.User
|
||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "list",
|
||||
ErrorMessage: "User not found",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trying to update the last admin
|
||||
if user.GetIsAdmin() && !isAdmin {
|
||||
// Count how many admins there are
|
||||
var adminCount int64
|
||||
if err := h.DB.Model(&db.User{}).Where("metadata->>'is_admin' = 'true'").Count(&adminCount).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to check admin count: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the last admin, prevent removal of admin status
|
||||
if adminCount <= 1 {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Cannot remove admin status from the last administrator",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := h.DB.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Database error: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update email
|
||||
if email != "" && email != user.Email {
|
||||
user.Email = email
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if password != "" {
|
||||
if password != passwordConfirm {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Passwords do not match",
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to set password: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update admin status and account lock status
|
||||
user.SetIsAdmin(isAdmin)
|
||||
user.SetAccountLocked(accountLocked)
|
||||
|
||||
// Save user changes
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to update user: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update roles if not admin
|
||||
if !isAdmin {
|
||||
// First, remove all existing roles
|
||||
if err := tx.Model(&user).Association("Roles").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to clear existing roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Then add the selected roles
|
||||
if len(roleIDs) > 0 {
|
||||
for _, roleIDStr := range roleIDs {
|
||||
roleID, err := strconv.ParseUint(roleIDStr, 10, 32)
|
||||
if err != nil {
|
||||
continue // Skip invalid IDs
|
||||
}
|
||||
|
||||
// Check if role exists
|
||||
var role db.Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
continue // Skip if role doesn't exist
|
||||
}
|
||||
|
||||
// Add role to user
|
||||
if err := tx.Model(&user).Association("Roles").Append(&role); err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to assign roles: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create audit log entry
|
||||
updaterID := getUserID(c)
|
||||
auditLog := &db.AuditLog{
|
||||
Action: "update",
|
||||
EntityType: "user",
|
||||
EntityID: user.ID,
|
||||
UserID: updaterID,
|
||||
Timestamp: time.Now(),
|
||||
Details: map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"is_admin": user.GetIsAdmin(),
|
||||
"account_locked": user.GetAccountLocked(),
|
||||
"password_changed": password != "",
|
||||
},
|
||||
}
|
||||
|
||||
if err := tx.Create(auditLog).Error; err != nil {
|
||||
tx.Rollback()
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to create audit log: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "edit",
|
||||
EditUser: &user,
|
||||
ErrorMessage: "Failed to commit transaction: " + err.Error(),
|
||||
}
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
_ = components.UserManagement(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For HTMX requests, render the user list with success message
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
// Fetch users from the database
|
||||
var users []db.User
|
||||
if err := h.DB.Find(&users).Error; err != nil {
|
||||
data := components.UserManagementData{
|
||||
ActiveTab: "list",
|
||||
ErrorMessage: "Failed to fetch users after update: " + err.Error(),
|
||||
}
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
return
|
||||
}
|
||||
|
||||
// Preload roles for each user
|
||||
for i := range users {
|
||||
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
data := components.UserManagementData{
|
||||
Users: users,
|
||||
ActiveTab: "list",
|
||||
SuccessMessage: "User updated successfully",
|
||||
}
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
} else {
|
||||
// Redirect to user list with success message for regular requests
|
||||
c.Redirect(http.StatusFound, "/admin/users?success=User+updated+successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteUser processes user deletion
|
||||
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
// Fetch user
|
||||
var user db.User
|
||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is an admin user
|
||||
if user.GetIsAdmin() {
|
||||
// Count how many admins there are
|
||||
var adminCount int64
|
||||
if err := h.DB.Model(&db.User{}).Where("metadata->>'is_admin' = 'true'").Count(&adminCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check admin count"})
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the last admin, prevent deletion
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete the last administrator"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if trying to delete yourself
|
||||
currentUserID := getUserID(c)
|
||||
if currentUserID == user.ID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete your own account"})
|
||||
return
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := h.DB.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create audit log entry before deletion
|
||||
auditLog := &db.AuditLog{
|
||||
Action: "delete",
|
||||
EntityType: "user",
|
||||
EntityID: user.ID,
|
||||
UserID: currentUserID,
|
||||
Timestamp: time.Now(),
|
||||
Details: map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"is_admin": user.GetIsAdmin(),
|
||||
"account_locked": user.GetAccountLocked(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := tx.Create(auditLog).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove roles association
|
||||
if err := tx.Model(&user).Association("Roles").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user roles"})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete user
|
||||
if err := tx.Delete(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the updated user list to return
|
||||
ctx := components.CreateTemplateContext(c)
|
||||
var users []db.User
|
||||
if err := h.DB.Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch updated user list"})
|
||||
return
|
||||
}
|
||||
|
||||
// Preload roles for each user
|
||||
for i := range users {
|
||||
if err := h.DB.Model(&users[i]).Association("Roles").Find(&users[i].Roles); err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Error loading roles for user %d: %v\n", users[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Render the updated user list
|
||||
data := components.UserManagementData{
|
||||
Users: users,
|
||||
ActiveTab: "list",
|
||||
SuccessMessage: "User deleted successfully",
|
||||
}
|
||||
|
||||
// Always use the partial for HTMX delete requests
|
||||
_ = components.UserManagementContent(data).Render(ctx, c.Writer)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
@@ -24,7 +25,7 @@ func (h *Handlers) HandleDashboard(c *gin.Context) {
|
||||
h.DB.Model(&db.JobHistory{}).Where("job_histories.status = 'running' AND job_histories.end_time IS NULL").Count(&totalJobs)
|
||||
|
||||
var completedJobs int64
|
||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "completed").Count(&completedJobs)
|
||||
h.DB.Model(&db.JobHistory{}).Where("status = ? AND start_time >= ?", "completed", time.Now().AddDate(0, 0, -1)).Count(&completedJobs)
|
||||
|
||||
var failedJobs int64
|
||||
h.DB.Model(&db.JobHistory{}).Where("status = ?", "failed").Count(&failedJobs)
|
||||
|
||||
@@ -99,17 +99,17 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
||||
admin.Use(h.AuthMiddleware())
|
||||
{
|
||||
// Main admin dashboard - requires admin role
|
||||
admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
|
||||
// admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
|
||||
|
||||
// User management routes
|
||||
userGroup := admin.Group("/users")
|
||||
userGroup.Use(h.PermissionMiddleware("users.view"))
|
||||
{
|
||||
userGroup.GET("", h.HandleUsers)
|
||||
userGroup.GET("/new", h.PermissionMiddleware("users.create"), h.AdminNewUserPage)
|
||||
userGroup.GET("/new", h.PermissionMiddleware("users.create"), h.HandleNewUser)
|
||||
userGroup.POST("", h.PermissionMiddleware("users.create"), h.HandleCreateUser)
|
||||
userGroup.GET("/:id/edit", h.PermissionMiddleware("users.edit"), h.HandleEditUser)
|
||||
userGroup.PUT("/:id", h.PermissionMiddleware("users.edit"), h.AdminUpdateUser)
|
||||
userGroup.PUT("/:id", h.PermissionMiddleware("users.edit"), h.HandleUpdateUser)
|
||||
userGroup.DELETE("/:id", h.PermissionMiddleware("users.delete"), h.HandleDeleteUser)
|
||||
userGroup.PUT("/:id/toggle-lock", h.PermissionMiddleware("users.edit"), h.AdminToggleLockUser)
|
||||
}
|
||||
|
||||
@@ -16,100 +16,100 @@ import (
|
||||
)
|
||||
|
||||
// HandleUsers handles the GET /admin/users route
|
||||
func (h *Handlers) HandleUsers(c *gin.Context) {
|
||||
var users []db.User
|
||||
if err := h.DB.Find(&users).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to retrieve users")
|
||||
return
|
||||
}
|
||||
// func (h *Handlers) HandleUsers(c *gin.Context) {
|
||||
// var users []db.User
|
||||
// if err := h.DB.Find(&users).Error; err != nil {
|
||||
// c.String(http.StatusInternalServerError, "Failed to retrieve users")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Create the data for the Users component, not UserManagement
|
||||
data := components.UsersData{
|
||||
Users: users,
|
||||
}
|
||||
// // Create the data for the Users component, not UserManagement
|
||||
// data := components.UsersData{
|
||||
// Users: users,
|
||||
// }
|
||||
|
||||
// Use the Users component from users.templ and ensure context flows through consistently
|
||||
ctx := h.CreateTemplateContext(c)
|
||||
components.Users(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
// // Use the Users component from users.templ and ensure context flows through consistently
|
||||
// ctx := h.CreateTemplateContext(c)
|
||||
// components.Users(ctx, data).Render(ctx, c.Writer)
|
||||
// }
|
||||
|
||||
// HandleNewUser handles the GET /admin/users/new route
|
||||
func (h *Handlers) HandleNewUser(c *gin.Context) {
|
||||
// Create the data for the UserForm component
|
||||
data := components.UserFormData{
|
||||
IsNew: true,
|
||||
ErrorMessage: "",
|
||||
}
|
||||
// func (h *Handlers) HandleNewUser(c *gin.Context) {
|
||||
// // Create the data for the UserForm component
|
||||
// data := components.UserFormData{
|
||||
// IsNew: true,
|
||||
// ErrorMessage: "",
|
||||
// }
|
||||
|
||||
// Use consistent context handling
|
||||
ctx := h.CreateTemplateContext(c)
|
||||
err := components.UserForm(ctx, data).Render(ctx, c.Writer)
|
||||
if err != nil {
|
||||
log.Printf("ERROR rendering UserForm: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering form: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// // Use consistent context handling
|
||||
// ctx := h.CreateTemplateContext(c)
|
||||
// err := components.UserForm(ctx, data).Render(ctx, c.Writer)
|
||||
// if err != nil {
|
||||
// log.Printf("ERROR rendering UserForm: %v", err)
|
||||
// c.String(http.StatusInternalServerError, "Error rendering form: %v", err)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
// HandleCreateUser handles the POST /admin/users/new route
|
||||
func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
isAdmin := c.PostForm("is_admin") == "on"
|
||||
// func (h *Handlers) HandleCreateUser(c *gin.Context) {
|
||||
// email := c.PostForm("email")
|
||||
// password := c.PostForm("password")
|
||||
// isAdmin := c.PostForm("is_admin") == "on"
|
||||
|
||||
// Check if email already exists
|
||||
var existingUser db.User
|
||||
if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
|
||||
c.String(http.StatusBadRequest, "Email already exists")
|
||||
return
|
||||
}
|
||||
// // Check if email already exists
|
||||
// var existingUser db.User
|
||||
// if err := h.DB.Where("email = ?", email).First(&existingUser).Error; err == nil {
|
||||
// c.String(http.StatusBadRequest, "Email already exists")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||
return
|
||||
}
|
||||
// // Hash the password
|
||||
// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
// if err != nil {
|
||||
// c.String(http.StatusInternalServerError, "Failed to hash password")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Create the user
|
||||
user := db.User{
|
||||
Email: email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
LastPasswordChange: time.Now(),
|
||||
}
|
||||
user.SetIsAdmin(isAdmin)
|
||||
// // Create the user
|
||||
// user := db.User{
|
||||
// Email: email,
|
||||
// PasswordHash: string(hashedPassword),
|
||||
// LastPasswordChange: time.Now(),
|
||||
// }
|
||||
// user.SetIsAdmin(isAdmin)
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to create user")
|
||||
return
|
||||
}
|
||||
// if err := h.DB.Create(&user).Error; err != nil {
|
||||
// c.String(http.StatusInternalServerError, "Failed to create user")
|
||||
// return
|
||||
// }
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
// c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
// }
|
||||
|
||||
// HandleDeleteUser handles the POST /admin/users/delete route
|
||||
func (h *Handlers) HandleDeleteUser(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
// func (h *Handlers) HandleDeleteUser(c *gin.Context) {
|
||||
// userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
// if err != nil {
|
||||
// c.String(http.StatusBadRequest, "Invalid user ID")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Don't allow deleting the current user
|
||||
currentUserID := c.GetUint("userID")
|
||||
if uint(userID) == currentUserID {
|
||||
c.String(http.StatusBadRequest, "Cannot delete your own account")
|
||||
return
|
||||
}
|
||||
// // Don't allow deleting the current user
|
||||
// currentUserID := c.GetUint("userID")
|
||||
// if uint(userID) == currentUserID {
|
||||
// c.String(http.StatusBadRequest, "Cannot delete your own account")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Delete the user
|
||||
if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to delete user")
|
||||
return
|
||||
}
|
||||
// // Delete the user
|
||||
// if err := h.DB.Delete(&db.User{}, userID).Error; err != nil {
|
||||
// c.String(http.StatusInternalServerError, "Failed to delete user")
|
||||
// return
|
||||
// }
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
// c.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
// }
|
||||
|
||||
// HandleRegisterPage handles the GET /register route
|
||||
func (h *Handlers) HandleRegisterPage(c *gin.Context) {
|
||||
@@ -304,37 +304,37 @@ func (h *Handlers) AdminCreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// HandleEditUser handles the GET /admin/users/:id/edit route
|
||||
func (h *Handlers) HandleEditUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// func (h *Handlers) HandleEditUser(c *gin.Context) {
|
||||
// id := c.Param("id")
|
||||
|
||||
var user db.User
|
||||
if err := h.DB.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
// var user db.User
|
||||
// if err := h.DB.First(&user, id).Error; err != nil {
|
||||
// c.Redirect(http.StatusFound, "/admin/users")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Get user's roles
|
||||
userRoles, err := h.DB.GetUserRoles(user.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching user roles: %v", err)
|
||||
}
|
||||
// // Get user's roles
|
||||
// userRoles, err := h.DB.GetUserRoles(user.ID)
|
||||
// if err != nil {
|
||||
// log.Printf("Error fetching user roles: %v", err)
|
||||
// }
|
||||
|
||||
// Get all available roles
|
||||
var allRoles []db.Role
|
||||
if err := h.DB.Find(&allRoles).Error; err != nil {
|
||||
log.Printf("Error fetching roles: %v", err)
|
||||
}
|
||||
// // Get all available roles
|
||||
// var allRoles []db.Role
|
||||
// if err := h.DB.Find(&allRoles).Error; err != nil {
|
||||
// log.Printf("Error fetching roles: %v", err)
|
||||
// }
|
||||
|
||||
// Use UserEdit component to match the form that's being submitted
|
||||
data := components.UserEditData{
|
||||
User: &user,
|
||||
Roles: allRoles,
|
||||
UserRoles: userRoles,
|
||||
IsNew: false,
|
||||
}
|
||||
ctx := h.CreateTemplateContext(c)
|
||||
components.UserEdit(ctx, data).Render(ctx, c.Writer)
|
||||
}
|
||||
// // Use UserEdit component to match the form that's being submitted
|
||||
// data := components.UserEditData{
|
||||
// User: &user,
|
||||
// Roles: allRoles,
|
||||
// UserRoles: userRoles,
|
||||
// IsNew: false,
|
||||
// }
|
||||
// ctx := h.CreateTemplateContext(c)
|
||||
// components.UserEdit(ctx, data).Render(ctx, c.Writer)
|
||||
// }
|
||||
|
||||
// AdminUpdateUser handles the PUT /admin/users/:id route
|
||||
func (h *Handlers) AdminUpdateUser(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user