feat: Update dependencies and enhance admin role management

- Upgraded `golang.org/x/crypto` to v0.36.0 and other indirect dependencies to their latest versions.
- Added functionality to assign admin roles to users upon creation in the main application logic.
- Introduced new templates for admin role management, including forms for creating and editing roles.
- Removed deprecated admin tools template to streamline the admin interface.
- Added audit logging for role changes to improve tracking and accountability.
This commit is contained in:
StarFleetCPTN committed 2025-03-22 16:55:36 -07:00
1 parent aa2149e2cf
commit 49a586db53
79 files changed
+11902 -10290

No files matched your search

+1 -1
View File
@@ -81,4 +81,4 @@ backups/
# Ignore binaries
gomft
gomft
+125
View File
@@ -0,0 +1,125 @@
package components
import (
"context"
)
// AdminLayout renders the main admin dashboard
templ AdminLayout(ctx context.Context) {
@LayoutWithContext("Administration", ctx) {
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
<!-- Sidebar Navigation -->
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="p-4 overflow-y-auto">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
<ul class="space-y-2">
<li>
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Dashboard
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
User Management
</a>
</li>
<li>
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Role Management
</a>
</li>
<li>
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Audit Logs
</a>
</li>
<li>
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Database Tools
</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
System Settings
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<div class="flex-1 p-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Statistics Cards -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">1,234</p>
</div>
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900">
<i class="fas fa-users text-blue-500 w-6 h-6 dark:text-blue-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">42</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-signal text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">System Health</p>
<p class="text-2xl font-bold text-green-500 dark:text-green-400">98%</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-heartbeat text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
</div>
<!-- Recent Activity Table -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Example Rows -->
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">admin@example.com</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Updated user permissions</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">2024-02-20 14:32:10</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
}
}
+441
View File
@@ -0,0 +1,441 @@
package components
import (
"context"
"fmt"
"time"
)
// AuditLogEntry represents an audit log entry for display
type AuditLogEntry struct {
ID uint
Action string
EntityType string
EntityID uint
Username string
UserID uint
Timestamp time.Time
Details map[string]interface{}
DetailsSummary string
}
type AuditLogsData struct {
Logs []AuditLogEntry
TotalPages int
CurrentPage int
TotalRecords int
FilterAction string
FilterEntity string
FilterUser string
FilterDateFrom string
FilterDateTo string
}
// AdminAuditLogs renders the audit logs page
templ AdminAuditLogs(ctx context.Context, data AuditLogsData) {
@LayoutWithContext("Audit Logs", ctx) {
<div id="audit-logs-container" style="min-height: 100vh;" class="audit-logs-page 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-clipboard-list w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Audit Logs
</h1>
<a href="/admin/audit/export" 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">
<i class="fas fa-file-export w-4 h-4 mr-2"></i>
Export Logs
</a>
</div>
<!-- Toast container for notifications -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></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>
<div id="error-message" class="hidden 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 error-title"></div>
<div class="error-details mt-1"></div>
</div>
<!-- Filters -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
</div>
<div class="p-4">
<form method="GET" action="/admin/audit" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- Action filter -->
<div>
<label for="filter-action" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Action</label>
<select id="filter-action" name="action" 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 Actions</option>
if data.FilterAction == "create" {
<option value="create" selected>Create</option>
} else {
<option value="create">Create</option>
}
if data.FilterAction == "update" {
<option value="update" selected>Update</option>
} else {
<option value="update">Update</option>
}
if data.FilterAction == "delete" {
<option value="delete" selected>Delete</option>
} else {
<option value="delete">Delete</option>
}
if data.FilterAction == "login" {
<option value="login" selected>Login</option>
} else {
<option value="login">Login</option>
}
if data.FilterAction == "logout" {
<option value="logout" selected>Logout</option>
} else {
<option value="logout">Logout</option>
}
</select>
</div>
<!-- Entity filter -->
<div>
<label for="filter-entity" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Entity Type</label>
<select id="filter-entity" name="entity" 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 Entities</option>
if data.FilterEntity == "user" {
<option value="user" selected>User</option>
} else {
<option value="user">User</option>
}
if data.FilterEntity == "role" {
<option value="role" selected>Role</option>
} else {
<option value="role">Role</option>
}
if data.FilterEntity == "config" {
<option value="config" selected>Config</option>
} else {
<option value="config">Config</option>
}
if data.FilterEntity == "job" {
<option value="job" selected>Job</option>
} else {
<option value="job">Job</option>
}
</select>
</div>
<!-- User filter -->
<div>
<label for="filter-user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<input type="text" id="filter-user" name="user" value={data.FilterUser} placeholder="Filter by username" 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>
<!-- Date range -->
<div>
<label for="filter-date-from" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Date</label>
<input type="date" id="filter-date-from" name="date_from" value={data.FilterDateFrom} 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="filter-date-to" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">To Date</label>
<input type="date" id="filter-date-to" name="date_to" value={data.FilterDateTo} 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>
<!-- Filter buttons -->
<div class="lg:col-span-5 flex items-center justify-end gap-2">
<a href="/admin/audit" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white rounded-lg border border-gray-200 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">
Clear Filters
</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
Apply Filters
</button>
</div>
</form>
</div>
</div>
<!-- Audit Logs Table -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Audit Log Entries</h3>
<span class="text-sm text-gray-600 dark:text-gray-400">Total entries: {fmt.Sprint(data.TotalRecords)}</span>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Entity</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
if len(data.Logs) == 0 {
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No audit logs found matching your filters.
</td>
</tr>
} else {
for _, log := range data.Logs {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.Timestamp.Format("2006-01-02 15:04:05")}
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.Username}
</td>
<td class="px-6 py-4 text-sm">
<span class={getActionClass(log.Action)}>
{log.Action}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
{log.EntityType}
<span class="text-xs text-gray-500 dark:text-gray-400">
(ID: {fmt.Sprint(log.EntityID)})
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<button
data-details={log.DetailsSummary}
data-modal-target="details-modal"
data-modal-toggle="details-modal"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline text-xs">
View Details
</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-700 dark:text-gray-400">
Showing page {fmt.Sprint(data.CurrentPage)} of {fmt.Sprint(data.TotalPages)}
</div>
<div class="flex items-center space-x-2">
if data.CurrentPage > 1 {
<a href={buildPaginationURL(data, data.CurrentPage-1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Previous
</a>
}
<!-- Page numbers -->
<div class="hidden sm:flex space-x-1">
<!-- First page -->
if data.CurrentPage > 3 {
<a href={buildPaginationURL(data, 1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
1
</a>
if data.CurrentPage > 4 {
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
}
}
<!-- Pages around current -->
for i := max(1, data.CurrentPage-2); i <= min(data.TotalPages, data.CurrentPage+2); i++ {
if i == data.CurrentPage {
<span class="px-3 py-1 text-sm text-white bg-blue-600 border border-blue-600 rounded-md dark:bg-blue-700">
{fmt.Sprint(i)}
</span>
} else {
<a href={buildPaginationURL(data, i)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
{fmt.Sprint(i)}
</a>
}
}
<!-- Last page -->
if data.CurrentPage < data.TotalPages-2 {
if data.CurrentPage < data.TotalPages-3 {
<span class="px-3 py-1 text-sm text-gray-500 dark:text-gray-400">...</span>
}
<a href={buildPaginationURL(data, data.TotalPages)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
{fmt.Sprint(data.TotalPages)}
</a>
}
</div>
if data.CurrentPage < data.TotalPages {
<a href={buildPaginationURL(data, data.CurrentPage+1)} class="px-3 py-1 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
Next
</a>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-2xl max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Audit Log Details
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="details-modal">
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5 space-y-4">
<pre id="details-content" class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-sm text-gray-900 dark:text-gray-300 overflow-x-auto"></pre>
</div>
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="details-modal" type="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-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Close</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Notyf toast notifications
const notyf = window.notyfInstance || new Notyf({
position: { x: 'right', y: 'top' },
duration: 3000,
ripple: true,
dismissible: true
});
// Check for URL parameters for success/error messages
const urlParams = new URLSearchParams(window.location.search);
const errorParam = urlParams.get('error');
const successParam = urlParams.get('success');
if (errorParam) {
notyf.error(decodeURIComponent(errorParam));
// Also show in the error box
const errorBox = document.getElementById('error-message');
const errorTitle = errorBox.querySelector('.error-title');
errorTitle.textContent = 'Error';
const errorDetails = errorBox.querySelector('.error-details');
errorDetails.textContent = decodeURIComponent(errorParam);
errorBox.classList.remove('hidden');
}
if (successParam) {
notyf.success(decodeURIComponent(successParam));
// Also show in the status box
const statusBox = document.getElementById('status-message');
statusBox.textContent = decodeURIComponent(successParam);
statusBox.classList.remove('hidden');
}
// Handle details modal
const detailsButtons = document.querySelectorAll('[data-modal-target="details-modal"]');
detailsButtons.forEach(button => {
button.addEventListener('click', function() {
const detailsData = this.getAttribute('data-details');
const detailsContent = document.getElementById('details-content');
detailsContent.textContent = detailsData;
// Format JSON if possible
try {
const jsonData = JSON.parse(detailsData);
detailsContent.textContent = JSON.stringify(jsonData, null, 2);
} catch (e) {
// Not valid JSON, leave as is
}
// Show the modal
const modal = document.getElementById('details-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
// Handle modal hide buttons
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('audit-logs-container').style.backgroundColor = '#111827';
}
// Handle theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('audit-logs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
// Helper functions
func getActionClass(action string) string {
baseClass := "px-2 py-1 text-xs rounded-full "
switch action {
case "create":
return baseClass + "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
case "update":
return baseClass + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
case "delete":
return baseClass + "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
case "login":
return baseClass + "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"
case "logout":
return baseClass + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
default:
return baseClass + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}
}
func buildPaginationURL(data AuditLogsData, page int) templ.SafeURL {
url := fmt.Sprintf("/admin/audit?page=%d", page)
if data.FilterAction != "" {
url += fmt.Sprintf("&action=%s", data.FilterAction)
}
if data.FilterEntity != "" {
url += fmt.Sprintf("&entity=%s", data.FilterEntity)
}
if data.FilterUser != "" {
url += fmt.Sprintf("&user=%s", data.FilterUser)
}
if data.FilterDateFrom != "" {
url += fmt.Sprintf("&date_from=%s", data.FilterDateFrom)
}
if data.FilterDateTo != "" {
url += fmt.Sprintf("&date_to=%s", data.FilterDateTo)
}
return templ.SafeURL(url)
}
+196 -106
View File
@@ -16,84 +16,29 @@ type BackupFile struct {
// AdminDatabaseTools renders the database tools page with backup and restore functionality
templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
@LayoutWithContext("Database Tools", ctx) {
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
<!-- Sidebar Navigation -->
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="p-4 overflow-y-auto">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
<ul class="space-y-2">
<li>
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Dashboard
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
User Management
</a>
</li>
<li>
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Role Management
</a>
</li>
<li>
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Audit Logs
</a>
</li>
<li>
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Database Tools
</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
System Settings
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<div class="flex-1 p-8">
<!-- Header -->
<div class="mb-6">
<div id="database-tools-container" style="min-height: 100vh;" class="database-tools-page 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-database w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Database Management
Database Tools
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage database backups, restoration and maintenance</p>
<form id="backup-form" method="POST" action="/admin/database/backup-database">
<button type="submit" 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">
<i class="fas fa-download w-4 h-4 mr-2"></i>
Create Backup
</button>
</form>
</div>
<!-- Database Actions Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<!-- Backup Database Card -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center mb-4">
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900 mr-4">
<i class="fas fa-save text-blue-500 w-6 h-6 dark:text-blue-300"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Backup Database</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Create a backup of the current database state.</p>
<form method="POST" action="/admin/backup-database">
<button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800">
<i class="fas fa-download mr-2"></i>
Create Backup
</button>
</form>
</div>
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- Vacuum Database Card -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<!-- Action Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Optimize Database Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-purple-100 p-3 rounded-full dark:bg-purple-900 mr-4">
<i class="fas fa-broom text-purple-500 w-6 h-6 dark:text-purple-300"></i>
@@ -101,7 +46,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Optimize Database</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Vacuum the database to reclaim space and optimize performance.</p>
<form method="POST" action="/admin/vacuum-database">
<form id="vacuum-form" method="POST" action="/admin/database/vacuum-database">
<button type="submit" class="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:bg-purple-700 dark:hover:bg-purple-800">
<i class="fas fa-magic mr-2"></i>
Optimize Database
@@ -110,7 +55,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
<!-- Clear Job History Card -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-orange-100 p-3 rounded-full dark:bg-orange-900 mr-4">
<i class="fas fa-eraser text-orange-500 w-6 h-6 dark:text-orange-300"></i>
@@ -118,7 +63,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Clear Job History</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Remove old job history records to free up database space.</p>
<form method="POST" action="/admin/clear-job-history">
<form id="clear-job-form" method="POST" action="/admin/database/clear-job-history">
<button type="submit" class="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:bg-orange-700 dark:hover:bg-orange-800">
<i class="fas fa-trash-alt mr-2"></i>
Clear Job History
@@ -127,7 +72,7 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
<!-- Export Configuration Card -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6 dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900 mr-4">
<i class="fas fa-file-export text-green-500 w-6 h-6 dark:text-green-300"></i>
@@ -149,10 +94,10 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
<!-- Database Backup List -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800 mt-8">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Database Backups</h3>
<a href="/admin/refresh-backups" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<a href="/admin/database/refresh-backups" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-sync-alt mr-1"></i>
Refresh
</a>
@@ -182,22 +127,17 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Created.Format("2006-01-02 15:04:05") }</td>
<td class="px-6 py-4 text-sm text-right">
<div class="flex items-center justify-end space-x-2">
<!-- Restore Button -->
<button type="button"
data-modal-target="confirm-restore-modal"
data-modal-toggle="confirm-restore-modal"
data-backup-name={ backup.Name }
class="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300">
class="text-yellow-600 hover:text-yellow-900 dark:text-yellow-400 dark:hover:text-yellow-300">
<i class="fas fa-undo-alt"></i>
</button>
<!-- Download Button -->
<a href={ templ.SafeURL("/admin/download-backup/" + backup.Name) }
<a href={ templ.SafeURL("/admin/database/download-backup/" + backup.Name) }
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-download"></i>
</a>
<!-- Delete Button -->
<button type="button"
data-modal-target="confirm-delete-modal"
data-modal-toggle="confirm-delete-modal"
@@ -215,13 +155,13 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
</div>
<!-- Upload Backup File -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800 mt-8">
<!-- Upload Backup Section -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mt-8">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Restore from Backup File</h3>
</div>
<div class="p-6">
<form method="POST" action="/admin/restore-database" enctype="multipart/form-data">
<form id="upload-backup-form" method="POST" action="/admin/database/restore-database" enctype="multipart/form-data">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Upload Backup File</label>
<div class="flex items-center justify-center w-full">
@@ -245,13 +185,15 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
</div>
<!-- Warning Section -->
<div class="p-4 mt-8 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 dark:bg-yellow-900/30 dark:border-yellow-800 dark:text-yellow-300">
<div class="flex">
<i class="fas fa-exclamation-triangle flex-shrink-0 w-5 h-5 mr-3"></i>
<div>
<h3 class="text-sm font-medium mb-2">Important Safety Information</h3>
<ul class="text-sm space-y-1 list-disc list-inside">
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<h4 class="font-medium text-gray-900 dark:text-white">Important Safety Information</h4>
<ul class="mt-2 text-gray-600 dark:text-gray-400 space-y-1 list-disc list-inside">
<li>Always create a backup before making significant changes</li>
<li>Restoring a backup will overwrite all current data</li>
<li>Database optimization may take time for large databases</li>
@@ -264,13 +206,13 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
<!-- Restore Confirmation Modal -->
<div id="confirm-restore-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-md max-h-full">
<div id="confirm-restore-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-4 md:p-5 text-center">
<i class="fas fa-exclamation-triangle mx-auto mb-4 text-yellow-400 text-3xl"></i>
<div class="p-6 text-center">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to restore this backup? This will overwrite your current database.</h3>
<form method="POST" id="restore-form" action="/admin/restore-database">
<form method="POST" id="restore-form" action="/admin/database/restore-database">
<input type="hidden" name="filename" id="restore-filename" value="" />
<button type="submit" class="text-white bg-yellow-600 hover:bg-yellow-800 focus:ring-4 focus:outline-none focus:ring-yellow-300 dark:focus:ring-yellow-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2">
Yes, restore it
@@ -285,11 +227,11 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
</div>
<!-- Delete Confirmation Modal -->
<div id="confirm-delete-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-md max-h-full">
<div id="confirm-delete-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-4 md:p-5 text-center">
<i class="fas fa-trash-alt mx-auto mb-4 text-red-400 text-3xl"></i>
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete this backup file?</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">
@@ -307,12 +249,104 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
<script>
// Handle restore modal
document.addEventListener('DOMContentLoaded', function() {
// Parse URL parameters for error and status messages
const urlParams = new URLSearchParams(window.location.search);
const errorParam = urlParams.get('error');
const detailsParam = urlParams.get('details');
const statusParam = urlParams.get('status');
// Function to create and show a toast
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// 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.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
toast.remove();
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'transition-opacity', 'duration-300');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Display error message if present
if (errorParam) {
const errorMessage = decodeURIComponent(errorParam).replace(/\+/g, ' ');
let details = '';
if (detailsParam) {
details = decodeURIComponent(detailsParam).replace(/\+/g, ' ');
}
const fullMessage = details ? `${errorMessage}: ${details}` : errorMessage;
showToast(fullMessage, 'error');
}
// Display status message if present
if (statusParam) {
const statusMessage = decodeURIComponent(statusParam).replace(/\+/g, ' ');
showToast(statusMessage, 'success');
}
// Display modals when buttons are clicked
const restoreButtons = document.querySelectorAll('[data-modal-target="confirm-restore-modal"]');
restoreButtons.forEach(button => {
button.addEventListener('click', function() {
const backupName = this.getAttribute('data-backup-name');
document.getElementById('restore-filename').value = backupName;
document.getElementById('restore-form').action = `/admin/restore-database/${backupName}`;
document.getElementById('restore-form').action = `/admin/database/restore-database/${backupName}`;
// Show the modal
const modal = document.getElementById('confirm-restore-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
@@ -321,7 +355,23 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const backupName = this.getAttribute('data-backup-name');
document.getElementById('delete-form').action = `/admin/delete-backup/${backupName}`;
document.getElementById('delete-form').action = `/admin/database/delete-backup/${backupName}`;
// Show the modal
const modal = document.getElementById('confirm-delete-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
// Handle modal hide buttons
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
@@ -336,6 +386,46 @@ templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
}
});
}
// Add form submission events
const backupForm = document.getElementById('backup-form');
if (backupForm) {
backupForm.addEventListener('submit', function(e) {
console.log('Backup form submitted');
});
}
const vacuumForm = document.getElementById('vacuum-form');
if (vacuumForm) {
vacuumForm.addEventListener('submit', function(e) {
console.log('Vacuum form submitted');
});
}
const clearJobForm = document.getElementById('clear-job-form');
if (clearJobForm) {
clearJobForm.addEventListener('submit', function(e) {
console.log('Clear job form submitted');
});
}
});
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('database-tools-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('database-tools-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
+175
View File
@@ -0,0 +1,175 @@
package components
import (
"context"
"fmt"
"strings"
)
type RoleFormData struct {
Role *Role
IsNew bool
ErrorMessage string
AllPermissions []string
}
templ AdminRoleForm(ctx context.Context, data RoleFormData) {
@LayoutWithContext(getFormTitle(data.IsNew), ctx) {
<div class="mb-6">
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="/admin" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
<i class="fas fa-shield-alt w-4 h-4 mr-2"></i>
Admin
</a>
</li>
<li>
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<a href="/admin/roles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">Roles</a>
</div>
</li>
<li aria-current="page">
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">
if data.IsNew {
New Role
} else {
Edit Role
}
</span>
</div>
</li>
</ol>
</nav>
</div>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400" role="alert">
<span class="font-medium">Error!</span> { data.ErrorMessage }
</div>
}
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 space-y-6">
<form method="POST" action={ getFormAction(data.IsNew, data.Role) }>
if !data.IsNew {
<input type="hidden" name="_method" value="PUT"/>
}
<!-- Role Name -->
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Role Name</label>
<input
type="text"
id="name"
name="name"
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"
placeholder="Enter role name"
required
if !data.IsNew {
value={ data.Role.Name }
}
/>
</div>
<!-- Role Description -->
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea
id="description"
name="description"
rows="3"
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"
placeholder="Enter role description"
>
if !data.IsNew {
{ data.Role.Description }
}
</textarea>
</div>
<!-- Permissions -->
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Permissions</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, perm := range data.AllPermissions {
<div class="flex items-center">
<input
type="checkbox"
id={ "perm_" + perm }
name="permissions[]"
value={ perm }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if !data.IsNew && hasPermission(data.Role, perm) {
checked
}
/>
<label for={ "perm_" + perm } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{ formatPermissionLabel(perm) }
</label>
</div>
}
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end space-x-3">
<a href="/admin/roles" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 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">
Cancel
</a>
<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">
if data.IsNew {
Create Role
} else {
Update Role
}
</button>
</div>
</form>
</div>
</div>
}
}
// Helper functions
func getFormTitle(isNew bool) string {
if isNew {
return "New Role"
}
return "Edit Role"
}
func getFormAction(isNew bool, role *Role) templ.SafeURL {
if isNew {
return "/admin/roles"
}
return templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID))
}
func hasPermission(role *Role, permission string) bool {
if role == nil || role.Permissions == nil {
return false
}
for _, p := range role.Permissions {
if p == permission {
return true
}
}
return false
}
func formatPermissionLabel(permission string) string {
// Convert permission strings like "users.create" to "Create Users"
parts := strings.Split(permission, ".")
if len(parts) != 2 {
return permission
}
resource := strings.Title(parts[0])
action := strings.Title(parts[1])
return action + " " + resource
}
+123
View File
@@ -0,0 +1,123 @@
package components
import (
"context"
"fmt"
)
type Role struct {
ID uint
Name string
Description string
Permissions []string
}
type RolesData struct {
Roles []Role
Error string
}
templ AdminRoles(ctx context.Context, data RolesData) {
@LayoutWithContext("Role Management", ctx) {
<!-- Roles Management Content -->
<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-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Role Management
</h1>
<a href="/admin/roles/new" 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">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Role
</a>
</div>
<!-- Roles Table -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Role Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Permissions</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
if len(data.Roles) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No roles found. Create your first role to get started.
</td>
</tr>
} else {
for _, role := range data.Roles {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">{ role.Name }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ role.Description }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap gap-1">
for _, perm := range role.Permissions {
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{ perm }
</span>
}
</div>
</td>
<td class="px-6 py-4 text-sm text-right">
<div class="flex items-center justify-end space-x-2">
<a href={ templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID)) } class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-edit"></i>
</a>
<button
data-role-id={ fmt.Sprint(role.ID) }
data-role-name={ role.Name }
data-modal-target="confirm-delete-modal"
data-modal-toggle="confirm-delete-modal"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="confirm-delete-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-md max-h-full">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<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">
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">
No, cancel
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 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`;
});
});
});
</script>
}
}
File diff suppressed because it is too large. Load diff
+237 -164
View File
@@ -7,7 +7,6 @@ import (
"github.com/starfleetcptn/gomft/components/providers/source"
"github.com/starfleetcptn/gomft/components/providers/destination"
"github.com/starfleetcptn/gomft/components/providers/common"
"strconv"
)
type ConfigFormData struct {
@@ -47,7 +46,7 @@ func getInitialData(config *db.TransferConfig) string {
sourceTeamDrive := ""
// Google Photos source fields
sourceReadOnly := false
sourceStartYear, _ := strconv.Atoi(getCurrentYear())
sourceStartYear := 2025
sourceIncludeArchived := false
filePattern := ""
@@ -75,7 +74,7 @@ func getInitialData(config *db.TransferConfig) string {
destTeamDrive := ""
// Google Photos destination fields
destReadOnly := false
destStartYear, _ := strconv.Atoi(getCurrentYear()) // Default to current year int
destStartYear := 2025
destIncludeArchived := false
archivePath := ""
@@ -117,7 +116,7 @@ func getInitialData(config *db.TransferConfig) string {
if config.SourceReadOnly != nil {
sourceReadOnly = *config.SourceReadOnly
}
sourceStartYear = config.SourceStartYear
// sourceStartYear = config.SourceStartYear
if config.SourceIncludeArchived != nil {
sourceIncludeArchived = *config.SourceIncludeArchived
}
@@ -152,7 +151,7 @@ func getInitialData(config *db.TransferConfig) string {
if config.DestReadOnly != nil {
destReadOnly = *config.DestReadOnly
}
destStartYear = config.DestStartYear
// destStartYear = config.DestStartYear
if config.DestIncludeArchived != nil {
destIncludeArchived = *config.DestIncludeArchived
}
@@ -280,166 +279,240 @@ func getInitialData(config *db.TransferConfig) string {
templ ConfigForm(ctx context.Context, data ConfigFormData) {
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
<div class="flex justify-center mb-6">
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
<!-- Main Content -->
<section class="py-8 px-4">
<div class="mx-auto max-w-3xl">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
<!-- Form Header -->
<div class="mb-8 text-center">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
<i class="fas fa-cog text-blue-600 dark:text-blue-300 text-2xl"></i>
</span>
</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{ getConfigFormTitle(data.IsNew) }
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure your file transfer settings using the form below
</p>
</div>
<!-- Main Form -->
<form
class="space-y-6"
if data.IsNew {
hx-post="/configs"
} else {
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
}
hx-target="body"
hx-redirect="/configs"
hx-boost="true"
x-data={ getInitialData(data.Config) }
x-init="$nextTick(() => {
// Ensure initial form state displays correctly on load
sourceType = sourceType || 'local';
destinationType = destinationType || 'local';
sourcePort = sourcePort || 22;
destPort = destPort || 22;
})"
>
<!-- Configuration Details Section -->
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Configuration Details</h3>
</div>
<p class="text-sm text-blue-700 dark:text-blue-300">
Give your configuration a descriptive name and set up the source and destination locations.
</p>
</div>
<!-- Name field -->
<div class="mb-2">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
<input
type="text"
id="name"
name="name"
x-model="name"
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="My Transfer Configuration"
/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
</div>
<!-- Source Configuration Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-upload mr-2 text-blue-500 dark:text-blue-400"></i>Source Configuration
</h3>
<!-- Source selection -->
@common.SourceSelection()
<!-- Source type specific forms -->
<template x-if="sourceType === 'local'">
@source.LocalSourceForm()
</template>
<template x-if="sourceType === 'sftp'">
@source.SFTPSourceForm()
</template>
<template x-if="sourceType === 'ftp'">
@source.FTPSourceForm()
</template>
<template x-if="sourceType === 's3'">
@source.S3SourceForm()
</template>
<template x-if="sourceType === 'minio'">
@source.MinIOSourceForm()
</template>
<template x-if="sourceType === 'smb'">
@source.SMBSourceForm()
</template>
<template x-if="sourceType === 'webdav'">
@source.WebDAVSourceForm()
</template>
<template x-if="sourceType === 'nextcloud'">
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'gdrive'">
@source.GoogleDriveSourceForm()
</template>
<template x-if="sourceType === 'gphotos'">
@source.GooglePhotosSourceForm()
</template>
</div>
<!-- File Pattern Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-filter mr-2 text-blue-500 dark:text-blue-400"></i>File Patterns
</h3>
<!-- File pattern fields -->
@common.FilePatternFields()
</div>
<!-- Destination Configuration Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration
</h3>
<!-- Destination selection -->
@common.DestinationSelection()
<!-- Destination type specific forms -->
<template x-if="destinationType === 'local'">
@destination.LocalDestinationForm()
</template>
<template x-if="destinationType === 'sftp'">
@destination.SFTPDestinationForm()
</template>
<template x-if="destinationType === 'ftp'">
@destination.FTPDestinationForm()
</template>
<template x-if="destinationType === 's3'">
@destination.S3DestinationForm()
</template>
<template x-if="destinationType === 'minio'">
@destination.MinIODestinationForm()
</template>
<template x-if="destinationType === 'smb'">
@destination.SMBDestinationForm()
</template>
<template x-if="destinationType === 'nextcloud'">
@destination.NextCloudDestinationForm()
</template>
<template x-if="destinationType === 'webdav'">
@destination.WebDAVDestinationForm()
</template>
<template x-if="destinationType === 'gdrive'">
@destination.GoogleDriveDestinationForm()
</template>
<template x-if="destinationType === 'gphotos'">
@destination.GooglePhotosDestinationForm()
</template>
</div>
<!-- Advanced Options Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cogs mr-2 text-blue-500 dark:text-blue-400"></i>Advanced Options
</h3>
<!-- Archive options -->
<div class="mb-6">
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Archive Settings</h4>
@common.ArchiveOptions()
</div>
<!-- Rclone flags -->
<div>
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Rclone Configuration</h4>
@common.RcloneFlags()
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<a href="/configs"
class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
<i class="fas fa-arrow-left mr-2"></i>Cancel
</a>
<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 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<span class="flex items-center">
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create Configuration
} else {
Save Changes
}
</span>
</button>
</div>
</form>
</div>
<!-- Help Card -->
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
</div>
<div class="ml-4">
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Configuration Tips</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure your file transfer settings carefully for optimal performance. Use file patterns to filter which files are transferred, and consider using archive options to keep track of processed files.
</p>
</div>
</div>
</div>
</div>
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
{ getConfigFormTitle(data.IsNew) }
</h1>
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
Set up your file transfer configuration
</p>
<form
class="space-y-8"
if data.IsNew {
hx-post="/configs"
} else {
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
}
hx-target="body"
hx-redirect="/configs"
hx-boost="true"
x-data={ getInitialData(data.Config) }
x-init="$nextTick(() => {
// Ensure initial form state displays correctly on load
sourceType = sourceType || 'local';
destinationType = destinationType || 'local';
sourcePort = sourcePort || 22;
destPort = destPort || 22;
})"
>
<!-- Name field -->
@common.NameField()
<!-- Source section -->
@common.SourceSelection()
<!-- Source type specific forms -->
<template x-if="sourceType === 'local'">
@source.LocalSourceForm()
</template>
<template x-if="sourceType === 'sftp'">
@source.SFTPSourceForm()
</template>
<template x-if="sourceType === 'ftp'">
@source.FTPSourceForm()
</template>
<template x-if="sourceType === 's3'">
@source.S3SourceForm()
</template>
<template x-if="sourceType === 'minio'">
@source.MinIOSourceForm()
</template>
<template x-if="sourceType === 'smb'">
@source.SMBSourceForm()
</template>
<template x-if="sourceType === 'webdav'">
@source.WebDAVSourceForm()
</template>
<template x-if="sourceType === 'nextcloud'">
@source.NextCloudSourceForm()
</template>
<template x-if="sourceType === 'gdrive'">
@source.GoogleDriveSourceForm()
</template>
<template x-if="sourceType === 'gphotos'">
@source.GooglePhotosSourceForm()
</template>
<!-- File pattern fields -->
@common.FilePatternFields()
<!-- Destination section -->
@common.DestinationSelection()
<!-- Destination type specific forms -->
<template x-if="destinationType === 'local'">
@destination.LocalDestinationForm()
</template>
<template x-if="destinationType === 'sftp'">
@destination.SFTPDestinationForm()
</template>
<template x-if="destinationType === 'ftp'">
@destination.FTPDestinationForm()
</template>
<template x-if="destinationType === 's3'">
@destination.S3DestinationForm()
</template>
<template x-if="destinationType === 'minio'">
@destination.MinIODestinationForm()
</template>
<template x-if="destinationType === 'smb'">
@destination.SMBDestinationForm()
</template>
<template x-if="destinationType === 'nextcloud'">
@destination.NextCloudDestinationForm()
</template>
<template x-if="destinationType === 'webdav'">
@destination.WebDAVDestinationForm()
</template>
<template x-if="destinationType === 'gdrive'">
@destination.GoogleDriveDestinationForm()
</template>
<template x-if="destinationType === 'gphotos'">
@destination.GooglePhotosDestinationForm()
</template>
<!-- Archive options -->
@common.ArchiveOptions()
<!-- Rclone flags -->
@common.RcloneFlags()
<!-- Form actions -->
<div class="flex justify-end mt-8">
<a href="/configs"
class="btn-secondary mr-4">
Cancel
</a>
<button
type="submit"
class="btn-primary">
<span class="flex items-center">
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create Configuration
} else {
Save Changes
}
</span>
</button>
</div>
</form>
</div>
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
<p>Configure your file transfer settings carefully for optimal performance</p>
</div>
</div>
</section>
}
}
+102 -86
View File
@@ -6,39 +6,30 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs
// Dialog component for confirmation dialogs using Flowbite modal
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-exclamation-circle text-yellow-400 fa-3x flex-shrink-0 mx-auto mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
hx-target="closest li"
hx-swap="delete"
data-config-name={ configName }
data-config-id={ fmt.Sprint(configID) }
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
onclick={ triggerConfigDelete(id, configID, configName) }>
{ confirmText }
</button>
<button type="button" class="py-2.5 px-5 ms-3 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" onclick={ hideConfigDialog(id) }>
Cancel
</button>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideConfigDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
hx-target="closest li"
hx-swap="delete"
data-config-name={ configName }
data-config-id={ fmt.Sprint(configID) }
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
onclick={ triggerConfigDelete(id, configID, configName) }>
{ confirmText }
</button>
</div>
</div>
</div>
@@ -50,11 +41,13 @@ script hideConfigDialog(id string) {
script showConfigDialog(id string) {
document.getElementById(id).classList.remove("hidden");
document.getElementById(id).classList.add("flex");
}
script triggerConfigDelete(dialogId string, configID uint, configName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Store data in a way that's accessible to event handlers
window.lastDeletedConfig = {
@@ -228,59 +221,57 @@ templ Configs(ctx context.Context, data ConfigsData) {
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
<div id="configs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="configs-page 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-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Transfer Configurations
</h1>
<a href="/configs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
<a href="/configs/new" 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">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Configuration
</a>
</div>
<div class="mt-6">
if len(data.Configs) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
<div class="mt-6">
<a href="/configs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
Create First Configuration
</a>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center 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-exchange-alt text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No configurations</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new transfer configuration.</p>
<a href="/configs/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 First Configuration
</a>
</div>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, config := range data.Configs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ config.Name }
</p>
<!-- Google Drive Authentication Badge -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
<i class="fas fa-exclamation-triangle mr-1 flex items-center"></i>
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
Authentication Required
</span>
}
<!-- Google Drive Authentication Status Indicator -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
<i class="fas fa-check-circle mr-1 flex items-center"></i>
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated
</span>
}
@@ -288,14 +279,14 @@ templ Configs(ctx context.Context, data ConfigsData) {
<div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Google Drive Authentication Button -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
<i class="fab fa-google mr-1"></i>
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate
</a>
}
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<!-- Add delete dialog for each configuration -->
@@ -303,7 +294,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
fmt.Sprintf("delete-config-dialog-%d", config.ID),
"Delete Configuration",
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
"btn-danger",
"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",
"Delete",
"delete",
config.ID,
@@ -312,25 +303,25 @@ templ Configs(ctx context.Context, data ConfigsData) {
<button
type="button"
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
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">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-upload w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Source: { config.SourceType }: { config.SourcePath }
</p>
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-download w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Destination: { config.DestinationType }: { config.DestinationPath }
</p>
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
</p>
@@ -345,22 +336,47 @@ templ Configs(ctx context.Context, data ConfigsData) {
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Configurations define how files are transferred between systems
</p>
</div>
<!-- Google Drive Auth Help -->
<div class="mt-4 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
</p>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Configurations define how files are transferred between systems</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('configs-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('configs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+215 -145
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"os/exec"
"strconv"
"strings"
)
type DashboardData struct {
@@ -13,174 +15,242 @@ type DashboardData struct {
CompletedToday int
FailedTransfers int
Configs map[uint]db.TransferConfig
RcloneVersion string
}
// GetRcloneVersion executes the rclone --version command and returns the version string
func GetRcloneVersion() string {
cmd := exec.Command("rclone", "--version")
output, err := cmd.Output()
if err != nil {
return "Unknown"
}
// Parse the version from output (typically first line contains "rclone v1.XX.X")
outputStr := string(output)
lines := strings.Split(outputStr, "\n")
if len(lines) > 0 {
parts := strings.Split(lines[0], " ")
for _, part := range parts {
if strings.HasPrefix(part, "v") {
return part
}
}
}
return "Unknown"
}
templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Dashboard
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<script>
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
</script>
</div>
<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>
<!-- Stats Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
</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="flex items-center justify-center w-8 h-8 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
<i class="fas fa-exchange-alt w-5 h-5 text-center"></i>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</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>
<div class="card overflow-hidden">
<div class="p-6 flex items-center">
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
</div>
<div>
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
</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>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Recent Jobs
</h3>
<!-- 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="flex items-center justify-center w-8 h-8 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
<i class="fas fa-check-circle w-5 h-5 text-center"></i>
</div>
<div class="card-body">
if len(data.RecentJobs) == 0 {
<div class="py-8 text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
</div>
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
<span>Create your first job</span>
<i class="fas fa-arrow-right ml-1"></i>
</a>
</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="flex items-center justify-center w-8 h-8 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-5 h-5 text-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-5 h-5 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>
} else {
<div class="flow-root">
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
for _, job := range data.RecentJobs {
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
if job.Status == "completed" {
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
</span>
} else if job.Status == "failed" {
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
</span>
} else {
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
</span>
}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
{ getConfigNameForHistory(job, data.Configs) }
<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-10 w-10 rounded-full bg-green-100 dark:bg-green-900">
<i class="fas fa-check-circle w-5 h-5 text-green-600 dark:text-green-300"></i>
</span>
} else if job.Status == "failed" {
<span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-red-100 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-5 h-5 text-red-600 dark:text-red-300"></i>
</span>
} else {
<span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900">
<i class="fas fa-tasks w-5 h-5 text-blue-600 dark:text-blue-300"></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 class="flex items-center mt-1">
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
</div>
<div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
<i class="fas fa-eye mr-1"></i>
View
</a>
</div>
</div>
</li>
}
</ul>
</div>
<div class="mt-6">
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
<i class="fas fa-list-ul mr-2"></i>
View all jobs
</a>
</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>
</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 class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-bolt mr-2 text-primary-500"></i>
Quick Actions
</h3>
</div>
<div class="card-body space-y-4">
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-plus-circle mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-play-circle mr-2"></i>
Create New Job
</a>
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-history mr-2"></i>
View Transfer History
</a>
<!-- System Status Card -->
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
<div class="space-y-2">
</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-5 h-5 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-4 h-4 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-4 h-4 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-4 h-4 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-xs text-secondary-500 dark:text-secondary-400">Server</span>
<span class="badge badge-success">Online</span>
<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-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
<span class="badge badge-success">Running</span>
<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-xs text-secondary-500 dark:text-secondary-400">Database</span>
<span class="badge badge-success">Connected</span>
<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>
+91
View File
@@ -0,0 +1,91 @@
package components
import (
"context"
"fmt"
)
// ErrorPage renders a generic error page with customizable title, message, and details
templ ErrorPage(ctx context.Context, code int, title, message, details string) {
@LayoutWithContext(fmt.Sprintf("Error %d - %s", code, title), ctx) {
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-lg bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
<div class="px-6 py-8">
<div class="text-center">
if code == 404 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-blue-100 dark:bg-blue-900">
<svg class="h-14 w-14 text-blue-600 dark:text-blue-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
} else if code == 401 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-yellow-100 dark:bg-yellow-900">
<svg class="h-14 w-14 text-yellow-600 dark:text-yellow-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
} else if code == 500 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-red-100 dark:bg-red-900">
<svg class="h-14 w-14 text-red-600 dark:text-red-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
} else {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-gray-100 dark:bg-gray-700">
<svg class="h-14 w-14 text-gray-600 dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
}
<h1 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
{ fmt.Sprintf("%d - %s", code, title) }
</h1>
<p class="mt-3 text-lg text-gray-500 dark:text-gray-400">
{ message }
</p>
if details != "" {
<div class="mt-4 p-4 rounded-md bg-gray-50 dark:bg-gray-700 overflow-auto">
<p class="text-sm text-gray-600 dark:text-gray-300 font-mono">
{ details }
</p>
</div>
}
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<a href="/dashboard" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
← Go to Dashboard
</a>
<a href="/" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
Go to Home →
</a>
</div>
</div>
</div>
}
}
// NotFoundError renders a 404 Not Found error page
templ NotFoundError(ctx context.Context) {
@ErrorPage(ctx, 404, "Page Not Found", "The page you're looking for doesn't exist or has been moved.", "")
}
// UnauthorizedError renders a 401 Unauthorized error page
templ UnauthorizedError(ctx context.Context) {
@ErrorPage(ctx, 401, "Unauthorized", "You don't have permission to access this page.", "Please log in or contact your administrator if you believe this is a mistake.")
}
// ServerError renders a 500 Internal Server Error page
templ ServerError(ctx context.Context, details string) {
@ErrorPage(ctx, 500, "Server Error", "Something went wrong on our end.", details)
}
// GenericError renders a generic error page
templ GenericError(ctx context.Context, title, message, details string) {
@ErrorPage(ctx, 0, title, message, details)
}
File diff suppressed because it is too large. Load diff
+183 -203
View File
@@ -6,102 +6,91 @@ import (
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
@LayoutWithContext("Forgot Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
if successMessage != "" {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span class="block sm:inline">{ successMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
We'll send a password reset link to this email
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
if successMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
<i class="fas fa-check-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
We'll send a password reset link to this email
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
// Reset password page for when users click the link from their email
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
@LayoutWithContext("Reset Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
+138 -106
View File
@@ -49,66 +49,88 @@ func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.Transfer
// HistoryContent renders only the content part of the history page for HTMX requests
templ HistoryContent(ctx context.Context, data HistoryData) {
if len(data.History) == 0 {
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
<i class="fas fa-inbox text-gray-400 dark:text-gray-500 text-3xl"></i>
</span>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">No transfer history</h3>
if data.SearchTerm != "" {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
No results found for "{ data.SearchTerm }". Try a different search term or
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
clear search
</button>.
</p>
} else if data.CurrentPage > 1 {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
No more results on this page.
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) }
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Return to first page
</button>.
</p>
} else {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transfer history will appear here once jobs have run.</p>
}
</div>
} else {
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, history := range data.History {
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
if history.Status == "completed" {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
Completed
</span>
} else if history.Status == "failed" {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
Failed
</span>
} else {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
{ history.Status }
</span>
}
</div>
<div>
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
<i class="fas fa-eye mr-1"></i>
class="text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-3 py-1.5 inline-flex items-center border border-gray-300 dark:border-gray-600 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 transition-colors">
<i class="fas fa-eye mr-1.5"></i>
View Details
</a>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
if history.EndTime != nil {
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
Duration: { history.EndTime.Sub(history.StartTime).String() }
</p>
}
</div>
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
if history.BytesTransferred > 0 {
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<i class="fas fa-upload mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
<p>
{ formatBytes(history.BytesTransferred) } transferred
</p>
@@ -116,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</div>
</div>
if history.ErrorMessage != "" {
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<div class="mt-2 p-3 rounded-lg border bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
<i class="fas fa-exclamation-triangle mr-1.5"></i>
Error: { history.ErrorMessage }
</p>
</div>
@@ -131,15 +153,17 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-white dark:bg-gray-800 rounded-lg shadow px-4">
<!-- Mobile pagination -->
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-prev-indicator"
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
<span class="flex items-center">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
@@ -148,7 +172,7 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</span>
</button>
} else {
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-left mr-1"></i> Previous
</span>
}
@@ -159,7 +183,8 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#mobile-next-indicator"
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
<span class="flex items-center">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
@@ -168,14 +193,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</span>
</button>
} else {
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
Next <i class="fas fa-chevron-right ml-1"></i>
</span>
}
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
<!-- Desktop pagination -->
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-secondary-700 dark:text-secondary-300">
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
to
@@ -185,23 +212,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
results
</p>
</div>
<div class="flex justify-center">
<nav class="flex gap-2" aria-label="Pagination">
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
if data.CurrentPage > 1 {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
hx-indicator="#prev-indicator"
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
<i class="fas fa-chevron-left"></i>
class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
<i class="fas fa-chevron-left text-xs"></i>
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
<i class="fas fa-chevron-left"></i>
<span class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-left text-xs"></i>
</span>
}
@@ -214,15 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
hx-indicator="#next-indicator"
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
<i class="fas fa-chevron-right"></i>
class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
<i class="fas fa-chevron-right text-xs"></i>
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
<i class="fas fa-chevron-right"></i>
<span class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
<i class="fas fa-chevron-right text-xs"></i>
</span>
}
</nav>
@@ -235,65 +264,65 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
templ History(ctx context.Context, data HistoryData) {
@LayoutWithContext("Transfer History", ctx) {
<div id="history-page" class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer History
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
<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>
<!-- Search and Pagination Controls -->
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
<div class="flex">
<div class="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-secondary-400 dark:text-secondary-500"></i>
</div>
<input
type="text"
name="search"
value={ data.SearchTerm }
placeholder="Search by job name or status..."
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
<input type="hidden" name="page" value="1" />
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
</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>
<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 != "" {
<button
type="submit"
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
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"
>
<span>Search</span>
<span id="search-indicator" class="htmx-indicator ml-1">
<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>
if data.SearchTerm != "" {
<button
hx-get="/history"
hx-target="#history-content"
hx-indicator="#clear-indicator"
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
<i class="fas fa-times mr-1"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
</form>
<div class="flex items-center">
<span class="text-sm text-secondary-700 dark:text-secondary-300">
}
</div>
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
name="pageSize"
@@ -301,7 +330,7 @@ templ History(ctx context.Context, data HistoryData) {
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
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>
@@ -309,16 +338,16 @@ templ History(ctx context.Context, data HistoryData) {
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-1">
<span id="size-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</span>
</label>
</div>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</form>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</div>
}
@@ -328,7 +357,7 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
// Show at most 5 page numbers with the current page in the middle when possible
for i := 1; i <= totalPages; i++ {
if i == currentPage {
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
{ fmt.Sprint(i) }
</span>
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
@@ -337,14 +366,17 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
>
{ fmt.Sprint(i) }
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else if i == currentPage-3 || i == currentPage+3 {
<span class="w-10 h-10 flex items-center justify-center">...</span>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
...
</span>
}
}
}
+124 -99
View File
@@ -6,130 +6,155 @@ import (
templ Home(ctx context.Context) {
@LayoutWithContext("Home", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center">
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
Welcome to GoMFT
</h1>
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
if isLoggedIn(ctx) {
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
<div class="flex justify-center">
<a href="/configs" class="btn-primary inline-flex items-center">
<span>View Configs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
<div class="flex justify-center">
<a href="/jobs" class="btn-primary inline-flex items-center">
<span>View Jobs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
<div class="flex justify-center">
<a href="/history" class="btn-primary inline-flex items-center">
<span>View History</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
A modern managed file transfer solution
</p>
</div>
if isLoggedIn(ctx) {
<!-- Logged-in User View -->
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Transfer Configs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
<a href="/configs" class="inline-flex items-center px-4 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">
View Configs
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
<!-- Quick Access Section -->
<div class="mt-12">
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
Quick Access
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
<!-- Schedule Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
<a href="/jobs" class="inline-flex items-center px-4 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">
View Jobs
<i class="fas fa-arrow-right ml-2"></i>
</a>
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
</a>
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
</a>
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
</div>
</div>
<!-- Track History Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
<a href="/history" class="inline-flex items-center px-4 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">
View History
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
<!-- Quick Access Section -->
<div class="mt-10">
<div class="flex items-center mb-4">
<i class="fas fa-bolt text-blue-500 w-6 h-6 mr-2"></i>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Quick Access</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-tachometer-alt text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">Dashboard</span>
</a>
<a href="/configs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-plus-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">New Config</span>
</a>
<a href="/jobs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-play-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">New Job</span>
</a>
<a href="/profile" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
<i class="fas fa-user-circle text-blue-600 dark:text-blue-300"></i>
</div>
<span class="font-medium text-gray-900 dark:text-white">Profile</span>
</a>
</div>
</div>
</div>
} else {
<!-- Login Card for Non-Logged-In Users -->
<div class="max-w-md mx-auto">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-6 text-center">
<div class="inline-flex items-center justify-center w-32 h-32 mb-6">
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Get Started</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">Log in to access the file transfer management system.</p>
<a href="/login" class="inline-flex items-center px-5 py-2.5 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-lock mr-2"></i>
Log In
</a>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
<i class="fas fa-lock mr-2"></i>
<span>Log In</span>
</a>
<!-- Features Preview -->
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
<ul class="space-y-3 text-left">
<div class="p-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
<ul class="space-y-3">
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
</li>
</ul>
</div>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-6">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">About GoMFT</h4>
<p>GoMFT is a modern, secure file transfer solution that allows you to effortlessly move files between different systems and storage providers with robust tracking and monitoring.</p>
</div>
</div>
</div>
</div>
}
</div>
}
}
+368 -547
View File
@@ -245,560 +245,381 @@ templ configSearchScript() {
templ JobForm(ctx context.Context, data JobFormData) {
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
@configSearchScript()
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-3xl w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-calendar-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
{ getJobTitle(data.IsNew) }
</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Configure your scheduled transfer job</p>
<!-- Main Content -->
<section class="py-8 px-4">
<div class="mx-auto max-w-3xl">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
<!-- Form Header -->
<div class="mb-8 text-center">
<div class="flex justify-center mb-4">
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
</span>
</div>
if data.IsNew {
<form
id="new-job-form"
class="space-y-6"
hx-post="/jobs"
hx-target="body"
hx-boost="true">
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="name"
id="name"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Daily Production Backup"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<div>
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
<!-- Search box -->
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
id="config-search"
placeholder="Search configurations..."
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
</div>
<!-- Configuration checkboxes -->
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list">
if len(data.Configs) > 0 {
for _, config := range data.Configs {
<div class="config-item py-2 flex items-center" data-name={ config.Name }>
<input
type="checkbox"
name="config_ids[]"
id={ fmt.Sprintf("config_%d", config.ID) }
value={ fmt.Sprint(config.ID) }
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
/>
<label for={ fmt.Sprintf("config_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
{ config.Name }
</label>
</div>
}
} else {
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
</div>
}
</div>
</div>
<!-- Selected Configurations Order List -->
<div class="mt-4">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
<!-- Selected items will be populated by JavaScript -->
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<div>
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="*/15 * * * *"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
name="enabled"
value="true"
if data.Job.GetEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Jobs that are not enabled will not run automatically on schedule.
</p>
</div>
<!-- Webhook Notification Settings -->
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-bell mr-2 text-primary-500"></i>
Webhook Notifications
</h3>
<div class="space-y-4">
<div class="flex items-center">
<input
type="checkbox"
id="webhook_enabled"
name="webhook_enabled"
value="true"
if data.Job.GetWebhookEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Send webhook notification on completion
</label>
</div>
<div class="pl-6 space-y-4">
<div>
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-link text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="url"
name="webhook_url"
id="webhook_url"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://example.com/webhook"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
The URL where notifications will be sent when jobs run
</p>
</div>
<div>
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="webhook_secret"
id="webhook_secret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Secret token for signing requests"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Used to sign webhook payloads (X-Hub-Signature-256 header)
</p>
</div>
<div>
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-code text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="webhook_headers"
id="webhook_headers"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder='{"X-Custom-Header": "value"}'/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Additional HTTP headers as JSON
</p>
</div>
<div class="space-y-2">
<div class="flex items-center">
<input
type="checkbox"
id="notify_on_success"
name="notify_on_success"
value="true"
if data.Job.GetNotifyOnSuccess() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_success" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on successful jobs
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="notify_on_failure"
name="notify_on_failure"
value="true"
if data.Job.GetNotifyOnFailure() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_failure" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on failed jobs
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="pt-5 flex justify-end space-x-3">
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary flex items-center justify-center px-4 py-2">
<i class="fas fa-plus mr-2"></i>
Create Job
</button>
</div>
</form>
} else {
<form
id="edit-job-form"
class="space-y-6"
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
hx-target="body"
hx-boost="true"
data-config-order={ data.Job.ConfigIDs }>
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="name"
id="name"
value={ data.Job.Name }
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Daily Production Backup"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<div>
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
<!-- Search box -->
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
id="config-search-edit"
placeholder="Search configurations..."
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
</div>
</div>
<!-- Configuration checkboxes -->
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list-edit">
if len(data.Configs) > 0 {
for _, config := range data.Configs {
<div class="config-item py-2 flex items-center" data-name={ config.Name }>
<input
type="checkbox"
name="config_ids[]"
id={ fmt.Sprintf("config_edit_%d", config.ID) }
value={ fmt.Sprint(config.ID) }
if configSelected(data.Job, config.ID) {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
/>
<label for={ fmt.Sprintf("config_edit_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
{ config.Name }
</label>
</div>
}
} else {
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
</div>
}
</div>
</div>
<!-- Selected Configurations Order List for edit -->
<div class="mt-4">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs-edit" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
<!-- Selected items will be populated by JavaScript -->
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<div>
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
value={ data.Job.Schedule }
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="*/15 * * * *"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div class="flex items-center">
<input
type="checkbox"
id="enabled"
name="enabled"
value="true"
if data.Job.GetEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Jobs that are not enabled will not run automatically on schedule.
</p>
</div>
<!-- Webhook Notification Settings -->
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-bell mr-2 text-primary-500"></i>
Webhook Notifications
</h3>
<div class="space-y-4">
<div class="flex items-center">
<input
type="checkbox"
id="webhook_enabled"
name="webhook_enabled"
value="true"
if data.Job.GetWebhookEnabled() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Send webhook notification on completion
</label>
</div>
<div class="pl-6 space-y-4">
<div>
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-link text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="url"
name="webhook_url"
id="webhook_url"
value={ data.Job.WebhookURL }
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://example.com/webhook"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
The URL where notifications will be sent when jobs run
</p>
</div>
<div>
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="webhook_secret"
id="webhook_secret"
value={ data.Job.WebhookSecret }
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Secret token for signing requests"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Used to sign webhook payloads (X-Hub-Signature-256 header)
</p>
</div>
<div>
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-code text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="webhook_headers"
id="webhook_headers"
value={ data.Job.WebhookHeaders }
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder='{"X-Custom-Header": "value"}'/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Additional HTTP headers as JSON
</p>
</div>
<div class="space-y-2">
<div class="flex items-center">
<input
type="checkbox"
id="notify_on_success"
name="notify_on_success"
value="true"
if data.Job.GetNotifyOnSuccess() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_success" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on successful jobs
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="notify_on_failure"
name="notify_on_failure"
value="true"
if data.Job.GetNotifyOnFailure() {
checked
}
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="notify_on_failure" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Notify on failed jobs
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="pt-5 flex justify-end space-x-3">
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary flex items-center justify-center px-4 py-2">
<i class="fas fa-save mr-2"></i>
Save Changes
</button>
</div>
</form>
}
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1"></i>
Jobs will run according to their schedule and execute the selected transfer configurations in the order specified
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{ getJobTitle(data.IsNew) }
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure your scheduled file transfer job using the form below
</p>
</div>
<!-- Job Form Information -->
<div class="p-4 mb-6 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Job Information</h3>
</div>
<p class="text-sm text-blue-700 dark:text-blue-300">
Jobs run on a schedule and can include one or more transfer configurations. Select which configurations to run and in what order.
</p>
</div>
<!-- Main Form -->
if data.IsNew {
<form id="new-job-form" hx-post="/jobs" hx-target="body" hx-boost="true" class="space-y-6">
<!-- Job Details Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-tag mr-2 text-blue-500 dark:text-blue-400"></i>Job Details
</h3>
<!-- Job name field -->
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Job Name
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-tag text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
name="name"
id="name"
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 ps-10 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="Daily Production Backup"
/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<!-- Schedule field -->
<div class="mb-6">
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Schedule (Cron Expression)
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-clock text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
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 ps-10 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="*/15 * * * *"
/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<!-- Enabled toggle -->
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
name="enabled"
value="true"
id="enabled"
if data.Job.GetEnabled() {
checked
}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Enable this job</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Disabled jobs won't run automatically but can still be triggered manually.
</p>
</div>
</div>
<!-- Configuration Selection Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog mr-2 text-blue-500 dark:text-blue-400"></i>Transfer Configurations
</h3>
<!-- Search field -->
<div class="mb-4">
<label for="config-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Search Configurations
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-search text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
id="config-search"
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 ps-10 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="Search configurations..."
/>
</div>
</div>
<!-- Configuration list -->
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Available Configurations
</label>
<div class="border border-gray-200 rounded-lg dark:border-gray-700 overflow-hidden">
<div class="max-h-56 overflow-y-auto" id="config-list">
if len(data.Configs) > 0 {
for _, config := range data.Configs {
<div class="config-item px-4 py-3 border-b last:border-b-0 border-gray-200 dark:border-gray-700 flex items-center" data-name={ config.Name }>
<input
type="checkbox"
name="config_ids[]"
id={ fmt.Sprintf("config_%d", config.ID) }
value={ fmt.Sprint(config.ID) }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for={ fmt.Sprintf("config_%d", config.ID) } class="ms-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer w-full">
{ config.Name }
</label>
</div>
}
} else {
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
No configurations available. <a href="/configs/new" class="text-blue-600 dark:text-blue-500 hover:underline">Create one</a>
</div>
}
</div>
</div>
</div>
<!-- Selected configurations order list -->
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-16 bg-gray-50 dark:bg-gray-800">
<!-- Items will be populated by JavaScript -->
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<!-- Form actions -->
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<a href="/jobs" class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
<i class="fas fa-arrow-left mr-2"></i>Cancel
</a>
<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 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus mr-2"></i>Create Job
</button>
</div>
</form>
} else {
<form id="edit-job-form" hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) } hx-target="body" hx-boost="true" data-config-order={ data.Job.ConfigIDs } class="space-y-6">
<!-- Job Details Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-tag mr-2 text-blue-500 dark:text-blue-400"></i>Job Details
</h3>
<!-- Job name field -->
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Job Name
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-tag text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
name="name"
id="name"
value={ data.Job.Name }
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 ps-10 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="Daily Production Backup"
/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Descriptive name for this job (optional). If not provided, the config name will be used.
</p>
</div>
<!-- Schedule field -->
<div class="mb-6">
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Schedule (Cron Expression)
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-clock text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
name="schedule"
id="schedule"
value={ data.Job.Schedule }
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 ps-10 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="*/15 * * * *"
/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
</p>
</div>
<!-- Enabled toggle -->
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
name="enabled"
value="true"
id="enabled"
if data.Job.GetEnabled() {
checked
}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Enable this job</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Disabled jobs won't run automatically but can still be triggered manually.
</p>
</div>
</div>
<!-- Configuration Selection Section -->
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog mr-2 text-blue-500 dark:text-blue-400"></i>Transfer Configurations
</h3>
<!-- Search field -->
<div class="mb-4">
<label for="config-search-edit" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Search Configurations
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
<i class="fas fa-search text-gray-500 dark:text-gray-400"></i>
</div>
<input
type="text"
id="config-search-edit"
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 ps-10 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="Search configurations..."
/>
</div>
</div>
<!-- Configuration list -->
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Available Configurations
</label>
<div class="border border-gray-200 rounded-lg dark:border-gray-700 overflow-hidden">
<div class="max-h-56 overflow-y-auto" id="config-list-edit">
if len(data.Configs) > 0 {
for _, config := range data.Configs {
<div class="config-item px-4 py-3 border-b last:border-b-0 border-gray-200 dark:border-gray-700 flex items-center" data-name={ config.Name }>
<input
type="checkbox"
name="config_ids[]"
id={ fmt.Sprintf("config_edit_%d", config.ID) }
value={ fmt.Sprint(config.ID) }
if configSelected(data.Job, config.ID) {
checked
}
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for={ fmt.Sprintf("config_edit_%d", config.ID) } class="ms-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer w-full">
{ config.Name }
</label>
</div>
}
} else {
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
No configurations available. <a href="/configs/new" class="text-blue-600 dark:text-blue-500 hover:underline">Create one</a>
</div>
}
</div>
</div>
</div>
<!-- Selected configurations order list -->
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
</label>
<div id="selected-configs-edit" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-16 bg-gray-50 dark:bg-gray-800">
<!-- Items will be populated by JavaScript -->
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>
Use the arrows to change the order in which configurations will execute.
</p>
</div>
</div>
<!-- Form actions -->
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
<a href="/jobs" class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
<i class="fas fa-arrow-left mr-2"></i>Cancel
</a>
<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 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-save mr-2"></i>Save Changes
</button>
</div>
</form>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-question-circle mr-2 text-primary-500"></i>
<span>Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">crontab.guru</a></span>
<!-- Help Card -->
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
</div>
<div class="ml-4">
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Scheduling Tips</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="font-medium underline hover:no-underline">crontab.guru</a> for a visual editor. Jobs can run multiple configurations in sequence, useful for multi-step transfer workflows.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
}
}
+151 -157
View File
@@ -22,169 +22,163 @@ templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
// This is used for testing
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-6">
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
</a>
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Job Run Details
</h1>
</div>
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
<div class="mb-6">
<a href="/dashboard" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center">
<i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
</a>
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
Job Run Details
</h1>
</div>
<!-- Job Run Information Card -->
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">{ data.Job.Name }</h3>
if data.JobHistory.Status == "completed" {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
<i class="fas fa-check mr-1"></i> Completed
</span>
} else if data.JobHistory.Status == "failed" {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
<i class="fas fa-times mr-1"></i> Failed
</span>
} else {
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
<i class="fas fa-spinner fa-spin mr-1"></i> Running
</span>
}
<!-- Job Run Information Card -->
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
if data.JobHistory.Status == "completed" {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 inline-flex items-center">
<i class="fas fa-check mr-2"></i> Completed
</span>
} else if data.JobHistory.Status == "failed" {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300 inline-flex items-center">
<i class="fas fa-times mr-2"></i> Failed
</span>
} else {
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 inline-flex items-center">
<i class="fas fa-spinner fa-spin mr-2"></i> Running
</span>
}
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
</dd>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Config: { data.Config.Name }</p>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-alt mr-1"></i> Start Time
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-check mr-1"></i> End Time
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
} else {
<span class="italic text-secondary-500">In progress</span>
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-clock mr-1"></i> Duration
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
} else {
<span class="italic text-secondary-500">In progress</span>
}
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-upload mr-1"></i> Data Transferred
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ formatBytes(data.JobHistory.BytesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-file mr-1"></i> Files Transferred
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ data.Job.Schedule }
</dd>
</div>
</dl>
</div>
</div>
<!-- Transfer Configuration Details -->
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Configuration
</h3>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.SourceType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.DestinationType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.SourcePath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.DestinationPath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">{ data.Config.FilePattern }</dd>
</div>
</dl>
</div>
</div>
<!-- Error Information (if any) -->
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-2"></i>
Error Information
</h3>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
} else {
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
}
</dd>
</div>
<div class="px-4 py-5 sm:p-6">
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
<pre class="text-sm text-red-800 dark:text-red-300 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
if data.JobHistory.EndTime != nil {
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
} else {
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
}
</dd>
</div>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-list-ul mr-2"></i> View All Jobs
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-edit mr-2"></i> Edit Job
</a>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-upload mr-2 text-gray-400 dark:text-gray-500"></i> Data Transferred
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ formatBytes(data.JobHistory.BytesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-file mr-2 text-gray-400 dark:text-gray-500"></i> Files Transferred
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
<i class="fas fa-calendar-day mr-2 text-gray-400 dark:text-gray-500"></i> Job Schedule
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{ data.Job.Schedule }
</dd>
</div>
</dl>
</div>
</div>
<!-- Transfer Configuration Details -->
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog mr-3 text-blue-600 dark:text-blue-400"></i>
Transfer Configuration
</h3>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Type</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.SourceType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Type</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.DestinationType }</span>
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Path</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.SourcePath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Path</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.DestinationPath }</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">File Pattern</dt>
<dd class="text-sm text-gray-900 dark:text-white font-mono">{ data.Config.FilePattern }</dd>
</div>
</dl>
</div>
</div>
<!-- Error Information (if any) -->
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
<div class="p-4 mb-8 text-red-800 border-l-4 border-red-300 bg-red-50 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 text-red-600 dark:text-red-500"></i>
<h3 class="text-lg font-medium">Error Information</h3>
</div>
<div class="mt-2">
<pre class="text-sm whitespace-pre-wrap font-mono p-3 bg-white dark:bg-gray-900 rounded-lg border border-red-200 dark:border-red-800">{ data.JobHistory.ErrorMessage }</pre>
</div>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href="/jobs" class="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 text-sm 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 inline-flex items-center justify-center">
<i class="fas fa-list-ul mr-2"></i> View All Jobs
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } 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 inline-flex items-center justify-center">
<i class="fas fa-edit mr-2"></i> Edit Job
</a>
</div>
</div>
}
+178 -144
View File
@@ -6,39 +6,43 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for confirmation dialogs - copied from admin_tools.templ
// Dialog component for confirmation dialogs using Flowbite modal
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden">
<div class="relative p-4 w-full max-w-md h-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-medium text-gray-900 dark:text-white flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-400 me-2 fa-2x"></i>
{ title }
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" onclick={ hideJobDialog(id) }>
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5 text-center">
<p class="text-gray-500 dark:text-gray-300 mb-5">
{ message }
</p>
<div class="flex justify-end space-x-3">
<button type="button" 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" onclick={ hideJobDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
hx-target="closest li"
hx-swap="delete"
data-job-name={ jobName }
data-job-id={ fmt.Sprint(jobID) }
id={ fmt.Sprintf("delete-btn-%d", jobID) }
onclick={ triggerJobDelete(id, jobID, jobName) }>
{ confirmText }
</button>
</div>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
{ title }
</h3>
</div>
<div class="px-6 py-4 text-center">
<p class="text-secondary-700 dark:text-secondary-300">
{ message }
</p>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick={ hideJobDialog(id) }>
Cancel
</button>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
hx-target="closest li"
hx-swap="delete"
data-job-name={ jobName }
data-job-id={ fmt.Sprint(jobID) }
id={ fmt.Sprintf("delete-btn-%d", jobID) }
onclick={ triggerJobDelete(id, jobID, jobName) }>
{ confirmText }
</button>
</div>
</div>
</div>
@@ -75,7 +79,7 @@ type JobsData struct {
}
templ Jobs(ctx context.Context, data JobsData) {
@LayoutWithContext("Transfer Jobs", ctx) {
@LayoutWithContext("Scheduled Jobs", ctx) {
<script>
// Debug notification system
console.log("Jobs template loaded, setting up notification system");
@@ -244,147 +248,177 @@ templ Jobs(ctx context.Context, data JobsData) {
});
</script>
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
Transfer Jobs
<div id="jobs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="jobs-page 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-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Scheduled Jobs
</h1>
<a href="/jobs/new" class="btn-primary">
<i class="fas fa-plus mr-2"></i>
<a href="/jobs/new" 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">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Job
</a>
</div>
<div class="mt-6">
if len(data.Jobs) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">No jobs</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new transfer job.</p>
<div class="mt-6">
<a href="/jobs/new" class="btn-primary">
<a href="/jobs/new" 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-plus mr-2"></i>
New Job
</a>
</div>
</div>
} else {
<div class="card overflow-hidden">
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, job := range data.Jobs {
<li>
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
if job.Name != "" {
{ job.Name }
} else {
{ job.Config.Name }
}
</p>
if job.GetEnabled() {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
Active
</span>
} else {
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
Inactive
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
hx-swap="none"
class="btn-primary btn-sm"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }
onclick="window.runJob(this)">
<i class="fas fa-play mr-1"></i>
Run Now
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
Edit
</a>
<!-- Add delete dialog for each job -->
@JobDialog(
fmt.Sprintf("delete-job-dialog-%d", job.ID),
"Delete Job",
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
"btn-danger",
"Delete",
"delete",
job.ID,
determineJobName(job),
)
<button
type="button"
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
class="btn-danger btn-sm">
<i class="fas fa-trash-alt mr-1"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-cogs flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Configs:
<span class="ml-1">
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
{ fmt.Sprintf("%d configurations", count) }
} else if job.ConfigID > 0 {
{ job.Config.Name }
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
<div class="relative overflow-x-auto">
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
for _, job := range data.Jobs {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
if job.Name != "" {
{ job.Name }
} else {
{ "None" }
{ job.Config.Name }
}
</span>
</p>
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Schedule: { job.Schedule }
</p>
if job.LastRun != nil {
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
</p>
if job.GetEnabled() {
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
Active
</span>
} else {
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
Inactive
</span>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<button
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
hx-swap="none"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }
onclick="window.runJob(this)">
<i class="fas fa-play mr-1"></i>
Run Now
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs 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-pen-to-square mr-1"></i>
Edit
</a>
<!-- Add delete dialog for each job -->
@JobDialog(
fmt.Sprintf("delete-job-dialog-%d", job.ID),
"Delete Job",
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
"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",
"Delete",
"delete",
job.ID,
determineJobName(job),
)
<button
type="button"
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
class="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900">
<i class="fas fa-trash mr-1"></i>
Delete
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex flex-wrap">
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-gear text-gray-400 dark:text-gray-500 mr-1.5"></i>
Configs:
<span class="ml-1">
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
{ fmt.Sprintf("%d configurations", count) }
} else if job.ConfigID > 0 {
{ job.Config.Name }
} else {
{ "None" }
}
</span>
</p>
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500 mr-1.5"></i>
Schedule: { job.Schedule }
</p>
if job.LastRun != nil {
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
<i class="fas fa-clock text-gray-400 dark:text-gray-500 mr-1.5"></i>
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
</p>
}
</div>
if job.NextRun != nil {
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
<i class="fas fa-hourglass-start text-gray-400 dark:text-gray-500 mr-1.5"></i>
<p>
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
</p>
</div>
}
</div>
if job.NextRun != nil {
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
<p>
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</li>
}
</ul>
</div>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
</p>
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('jobs-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('jobs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+426 -334
View File
@@ -41,7 +41,7 @@ templ Layout(title string) {
templ LayoutWithContext(title string, ctx context.Context) {
<!DOCTYPE html>
<html lang="en" class="light h-full p-0 m-0">
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
@@ -55,7 +55,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
<title>{ title } - GoMFT</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<!-- Tailwind CSS with Flowbite -->
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.js"></script>
<script src="/static/js/app.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="stylesheet" href="/static/css/app.css"/>
@@ -113,393 +116,482 @@ templ LayoutWithContext(title string, ctx context.Context) {
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
}
},
fontFamily: {
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
},
boxShadow: {
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
},
}
}
}
}
</script>
<style type="text/tailwindcss">
/* Reset default browser margins and padding */
<style>
html, body {
margin: 0;
padding: 0;
overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-x: hidden;
width: 100%;
}
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
.btn-secondary {
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
}
.btn-warning {
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
}
.form-input {
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
}
.form-checkbox {
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
}
.card-header {
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
}
.card-body {
@apply p-6;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
}
.badge-danger {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.badge-info {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.table-container {
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
}
.table {
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
}
.table thead {
@apply bg-secondary-50 dark:bg-secondary-900;
}
.table th {
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
}
.table tbody {
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
}
.table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
}
.nav-link {
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
}
.nav-link-active {
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
}
}
/* Dark mode styles */
.dark body {
@apply bg-secondary-900 text-secondary-100;
}
.dark .bg-white {
@apply bg-secondary-800;
}
.dark .text-secondary-800 {
@apply text-secondary-100;
}
.dark .text-secondary-700 {
@apply text-secondary-200;
}
.dark .text-secondary-500 {
@apply text-secondary-300;
}
.dark .hover\:text-secondary-700:hover {
@apply text-secondary-100;
}
.dark .hover\:bg-secondary-100:hover {
@apply bg-secondary-700;
}
.dark .bg-secondary-50 {
@apply bg-secondary-900;
}
.dark .bg-secondary-200 {
@apply bg-secondary-700;
}
.dark .shadow-sm {
@apply shadow-secondary-900;
}
/* Additional dark mode improvements */
.dark .bg-secondary-50 {
@apply bg-secondary-900;
}
.dark .bg-secondary-100 {
@apply bg-secondary-800;
}
.dark .border-secondary-200 {
@apply border-secondary-700;
}
/* Custom animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-in-out;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
.pb-mobile-nav {
padding-bottom: 4rem;
}
::-webkit-scrollbar-track {
background: theme('colors.secondary.100');
border-radius: 4px;
@media (min-width: 640px) {
.pb-mobile-nav {
padding-bottom: 0;
}
}
.dark ::-webkit-scrollbar-track {
background: theme('colors.secondary.800');
/* Ensure dark mode background extends to full height */
body.dark {
background-color: #111827; /* gray-900 */
}
::-webkit-scrollbar-thumb {
background: theme('colors.secondary.300');
border-radius: 4px;
/* Force dark background on specific pages */
body.dark .configs-page,
body.dark .jobs-page {
background-color: #111827 !important; /* gray-900 */
min-height: 100vh;
}
.dark ::-webkit-scrollbar-thumb {
background: theme('colors.secondary.600');
/* Ensure dark mode fills entire page */
body.dark {
background-color: #111827 !important;
}
::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.500');
/* Force container backgrounds */
#jobs-container.dark, #configs-container.dark {
background-color: #111827 !important;
min-height: 100vh;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: theme('colors.secondary.500');
/* Override any white backgrounds in dark mode */
body.dark .bg-white {
background-color: #1f2937 !important;
}
</style>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<script>
// Immediate theme application
// This script runs before the DOM is fully loaded
(function() {
const isDark = localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('dark');
// Apply dark theme to containers
const containers = ['jobs-container', 'configs-container'];
containers.forEach(function(id) {
const container = document.getElementById(id);
if (container) {
container.classList.add('dark');
container.style.backgroundColor = '#111827';
}
});
});
}
})();
</script>
</head>
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;">
if isLoggedIn(ctx) {
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Application Shell -->
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Desktop Sidebar -->
<aside class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
<div class="flex items-center flex-shrink-0 px-4">
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt mr-2"></i>
GoMFT
</a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/dashboard" class="nav-link">
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
</div>
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
<div class="space-y-1">
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Dashboard
</a>
<a href="/configs" class="nav-link">
<i class="fas fa-cogs mr-1"></i> Configs
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
Configs
</a>
<a href="/jobs" class="nav-link">
<i class="fas fa-tasks mr-1"></i> Jobs
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
Jobs
</a>
<a href="/history" class="nav-link">
<i class="fas fa-history mr-1"></i> History
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
History
</a>
<a href="/files" class="nav-link">
<i class="fas fa-file-alt mr-1"></i> Files
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Files
</a>
if isAdmin(ctx) {
<a href="/admin/users" class="nav-link">
<i class="fas fa-users mr-1"></i> Users
</a>
<a href="/admin/tools" class="nav-link">
<i class="fas fa-tools mr-1"></i> Admin Tools
</a>
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Administration
</p>
<div class="mt-1 space-y-1">
<!-- Admin dropdown menu -->
<button
type="button"
class="flex items-center w-full p-2 text-sm font-medium text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
aria-controls="dropdown-admin"
data-collapse-toggle="dropdown-admin"
aria-expanded="true"
>
<i class="fas fa-shield-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
<span class="flex-1 text-left whitespace-nowrap">Admin Console</span>
<i class="fas fa-chevron-down w-4 h-4"></i>
</button>
<ul id="dropdown-admin" class="hidden py-2 space-y-1">
<li>
<a href="/admin" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tachometer-alt w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Dashboard
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Users
</a>
</li>
<li>
<a href="/admin/roles" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Roles
</a>
</li>
<li>
<a href="/admin/audit" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs
</a>
</li>
<li>
<a href="/admin/database" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center p-2 pl-11 w-full text-sm font-normal text-gray-700 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Settings
</a>
</li>
</ul>
</div>
</div>
}
</div>
</div>
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<button
id="theme-toggle"
type="button"
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
onclick="toggleTheme()"
>
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
<!-- Mobile menu button -->
<div class="sm:hidden flex items-center">
<button
id="mobile-menu-button"
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
x-data="{}"
@click="$dispatch('toggle-mobile-menu')"
>
</nav>
</div>
</aside>
<!-- Mobile menu -->
<div class="md:hidden fixed top-0 z-30 w-full">
<div class="bg-white dark:bg-gray-800 shadow-sm">
<div class="flex items-center justify-between px-4 py-3">
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt text-xl"></i>
</a>
<div class="flex items-center space-x-3">
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
<i class="fas fa-bars text-lg"></i>
</button>
</div>
<!-- User Menu -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
</div>
</button>
<div x-show="open"
@click.away="open = false"
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
tabindex="-1">
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
</div>
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
<i class="fas fa-user-circle mr-2"></i> Profile
</a>
<form method="POST" action="/logout">
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
</button>
</form>
</div>
<!-- User menu (same as desktop) -->
</div>
</div>
</div>
</div>
</nav>
<!-- Mobile Navigation Menu -->
<div
x-data="{ open: false }"
x-on:toggle-mobile-menu.window="open = !open"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2"
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md w-full left-0 right-0"
>
<div class="max-w-7xl mx-auto px-2 pt-2 pb-3 space-y-1">
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
</a>
<a href="/configs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-cogs mr-2"></i> Configs
</a>
<a href="/jobs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tasks mr-2"></i> Jobs
</a>
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-history mr-2"></i> History
</a>
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-file-alt mr-2"></i> Files
</a>
if isAdmin(ctx) {
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-users mr-2"></i> Users
</a>
<a href="/admin/tools" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tools mr-2"></i> Admin Tools
</a>
}
<a href="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-user-circle mr-2"></i> Profile
</a>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900 hover:text-red-700 dark:hover:text-red-400">
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
</button>
</form>
<!-- Main Content -->
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
<!-- Main header -->
<header class="bg-white dark:bg-gray-800 shadow-sm">
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center space-x-3">
<button data-drawer-target="desktop-menu" data-drawer-toggle="desktop-menu" class="md:hidden p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
<i class="fas fa-bars text-lg"></i>
</button>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
</div>
<!-- Theme toggle and user menu -->
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<button
id="theme-toggle"
type="button"
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
onclick="toggleTheme()"
>
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
<!-- Notification Bell -->
<div class="relative">
<button
type="button"
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 relative"
id="notification-bell"
data-dropdown-toggle="notification-dropdown"
data-dropdown-placement="bottom-end">
<span class="sr-only">View notifications</span>
<i class="fas fa-bell"></i>
<!-- Notification badge -->
<div class="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full -top-1 -right-1">3</div>
</button>
<!-- Notification dropdown -->
<div class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl"
id="notification-dropdown"
style="min-width: 320px; width: 100%;"
data-dropdown-placement="bottom-end">
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
Notifications
</div>
<div>
<!-- Job completed notification -->
<a href="#" class="flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600">
<div class="flex-shrink-0">
<div class="w-11 h-11 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
<i class="fas fa-check-circle text-green-600 dark:text-green-300"></i>
</div>
<div class="flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 bg-primary-700 rounded-full border border-white dark:border-gray-700">
<i class="fas fa-check text-white text-xs"></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">
<span class="font-semibold text-gray-900 dark:text-white">Job Complete:</span> Daily backup to AWS S3
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
10 minutes ago
</div>
</div>
</a>
<!-- Job failed notification -->
<a href="#" class="flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600">
<div class="flex-shrink-0">
<div class="w-11 h-11 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300"></i>
</div>
<div class="flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 bg-red-600 rounded-full border border-white dark:border-gray-700">
<i class="fas fa-times text-white text-xs"></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">
<span class="font-semibold text-gray-900 dark:text-white">Job Failed:</span> SFTP transfer to customer portal
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
1 hour ago
</div>
</div>
</a>
<!-- Config updated notification -->
<a href="#" class="flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600">
<div class="flex-shrink-0">
<div class="w-11 h-11 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
<i class="fas fa-cog text-blue-600 dark:text-blue-300"></i>
</div>
<div class="flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 bg-blue-500 rounded-full border border-white dark:border-gray-700">
<i class="fas fa-wrench text-white text-xs"></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">
<span class="font-semibold text-gray-900 dark:text-white">Config Updated:</span> AWS credentials refreshed
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
3 hours ago
</div>
</div>
</a>
<!-- New user notification -->
<a href="#" class="flex py-3 px-4 hover:bg-gray-100 dark:hover:bg-gray-600">
<div class="flex-shrink-0">
<div class="w-11 h-11 rounded-full bg-purple-100 flex items-center justify-center dark:bg-purple-900">
<i class="fas fa-user-plus text-purple-600 dark:text-purple-300"></i>
</div>
<div class="flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 bg-purple-500 rounded-full border border-white dark:border-gray-700">
<i class="fas fa-user text-white text-xs"></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">
<span class="font-semibold text-gray-900 dark:text-white">User Added:</span> New admin added to the system
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
5 hours ago
</div>
</div>
</a>
</div>
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
<div class="inline-flex items-center">
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
View all
</div>
</a>
</div>
</div>
<!-- User menu dropdown -->
<div class="relative">
<button type="button"
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
data-dropdown-toggle="user-dropdown"
data-dropdown-placement="bottom-end">
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
</div>
</button>
<!-- Dropdown menu -->
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
id="user-dropdown">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a href="/profile"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">
<i class="fas fa-user-circle mr-2 w-4"></i>
Profile
</a>
</li>
<li>
<form method="POST" action="/logout">
<button type="submit"
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
Sign out
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
<div class="py-6 bg-gray-50 dark:bg-gray-900">
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Notification area -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
</div>
} else {
<!-- Public page layout -->
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
<main class="flex-grow">
{ children... }
</main>
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
}
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
<!-- Hidden notification area for HTMX targets -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</main>
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
<div class="flex justify-center items-center space-x-4 mt-1">
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fab fa-github mr-1"></i>GitHub
</a>
</p>
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
</a>
</p>
</div>
</div>
</footer>
if isLoggedIn(ctx) {
<!-- Mobile Bottom Navigation -->
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
<div class="grid grid-cols-6 h-16">
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tachometer-alt text-lg"></i>
<span class="text-xs mt-1">Dashboard</span>
</a>
<a href="/configs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-cogs text-lg"></i>
<span class="text-xs mt-1">Configs</span>
</a>
<a href="/jobs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tasks text-lg"></i>
<span class="text-xs mt-1">Jobs</span>
</a>
<a href="/history" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-history text-lg"></i>
<span class="text-xs mt-1">History</span>
</a>
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-file-alt text-lg"></i>
<span class="text-xs mt-1">Files</span>
</a>
<button
type="button"
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
x-data="{}"
@click="$dispatch('toggle-mobile-menu')"
>
<i class="fas fa-ellipsis-h text-lg"></i>
<span class="text-xs mt-1">More</span>
</button>
</div>
</div>
}
<script>
// Add script to ensure dark mode is properly applied
document.addEventListener('DOMContentLoaded', function() {
// Apply body dark class when theme changes
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.body.classList.add('dark');
// Also apply to containers
const jobsContainer = document.getElementById('jobs-container');
const configsContainer = document.getElementById('configs-container');
if (jobsContainer) jobsContainer.classList.add('dark');
if (configsContainer) configsContainer.classList.add('dark');
}
// Initialize admin dropdown toggle if available
const adminDropdownToggle = document.querySelector('[data-collapse-toggle="dropdown-admin"]');
const adminDropdown = document.getElementById('dropdown-admin');
if (adminDropdownToggle && adminDropdown) {
// Check if we should show the dropdown (if current page is under admin section)
const currentPath = window.location.pathname;
if (currentPath.startsWith('/admin')) {
adminDropdown.classList.remove('hidden');
}
// Add click event listener
adminDropdownToggle.addEventListener('click', function() {
adminDropdown.classList.toggle('hidden');
});
}
});
</script>
</body>
</html>
}
+104 -116
View File
@@ -7,131 +7,119 @@ import (
templ Login(ctx context.Context, errorMessage string) {
@LayoutWithContext("Login", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Sign In</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Access your GoMFT account</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
} else {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
}
<form
class="space-y-6"
method="POST"
action="/login"
x-data="{
email: '',
password: '',
loading: false,
validate() {
return this.email && this.password;
}
}">
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="you@example.com"/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<label for="remember-me" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Remember me</label>
</div>
<div class="text-sm">
<a href="/forgot-password" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">Forgot password?</a>
</div>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign in
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Sign In</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Access your GoMFT account</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
if errorMessage != "" {
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
<i class="fas fa-check-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
} else {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
}
<form
class="space-y-6"
method="POST"
action="/login"
x-data="{
email: '',
password: '',
loading: false,
validate() {
return this.email && this.password;
}
}">
<div class="space-y-4">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="you@example.com"/>
</div>
</div>
<div>
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800"/>
<label for="remember-me" class="ms-2 text-sm text-gray-900 dark:text-gray-300">Remember me</label>
</div>
<a href="/forgot-password" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">Forgot password?</a>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign in
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-300">
<i class="fas fa-info-circle mr-1"></i>
Contact an administrator to create an account
</p>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
+162 -122
View File
@@ -5,55 +5,53 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for 2FA disable confirmation
// Dialog component for 2FA disable confirmation with Flowbite styling
templ TwoFactorDisableDialog() {
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
Disable Two-Factor Authentication
</h3>
</div>
<div class="px-6 py-4">
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
Are you sure you want to disable two-factor authentication? This will make your account less secure.
</p>
<div class="space-y-4">
<div>
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<div id="disable-2fa-dialog" tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full inset-0 overflow-y-auto overflow-x-hidden bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="p-5 text-center">
<div class="h-14 w-14 rounded-full bg-yellow-100 dark:bg-yellow-900 p-2 flex items-center justify-center mx-auto mb-5">
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
</div>
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
Disable Two-Factor Authentication
</h3>
<p class="mb-6 text-gray-500 dark:text-gray-400">
Are you sure you want to disable two-factor authentication? This will make your account less secure.
</p>
<div class="mb-6">
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock mr-1"></i> Current Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="current-password-2fa"
name="current_password"
class="form-input pl-10 w-full"
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 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="Enter your current password"
required/>
</div>
</div>
<div id="disable-2fa-result"></div>
<div id="disable-2fa-result" class="mb-5"></div>
<div class="flex justify-center space-x-4">
<button
type="button"
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
onclick="submitDisable2FA()">
<i class="fas fa-times mr-2"></i>
Disable 2FA
</button>
<button type="button" 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" onclick="hideDisable2FADialog()">
Cancel
</button>
</div>
</div>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
Cancel
</button>
<button
type="button"
class="btn-danger"
onclick="submitDisable2FA()">
<i class="fas fa-times mr-1"></i>
Disable 2FA
</button>
</div>
</div>
</div>
}
@@ -68,6 +66,7 @@ templ Profile(ctx context.Context, user db.User) {
// Global functions for dialog control
window.hideDisable2FADialog = function() {
document.getElementById('disable-2fa-dialog').classList.add('hidden');
document.getElementById('disable-2fa-dialog').classList.remove('flex');
document.getElementById('current-password-2fa').value = '';
document.getElementById('disable-2fa-result').innerHTML = '';
};
@@ -75,14 +74,18 @@ templ Profile(ctx context.Context, user db.User) {
window.showDisable2FADialog = function() {
console.log('Showing 2FA disable dialog');
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
document.getElementById('disable-2fa-dialog').classList.add('flex');
};
window.submitDisable2FA = function() {
const password = document.getElementById('current-password-2fa').value;
if (!password) {
document.getElementById('disable-2fa-result').innerHTML = `
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
<span class="block sm:inline">Current password is required</span>
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>Current password is required</span>
</div>
</div>`;
return;
}
@@ -111,64 +114,88 @@ templ Profile(ctx context.Context, user db.User) {
</script>
@TwoFactorDisableDialog()
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Profile 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-user-circle w-6 h-6 mr-2 text-blue-500"></i>
Profile
</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Profile Information Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-id-card mr-2 text-primary-500"></i>
Profile Information
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Profile Information
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Personal details and application settings</p>
</div>
</div>
</div>
<div class="card-body">
<div class="p-5">
<dl class="space-y-6">
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
if user.GetIsAdmin() {
<span class="badge badge-success">
<i class="fas fa-user-shield mr-1"></i> Administrator
</span>
} else {
<span class="badge badge-info">
<i class="fas fa-user mr-1"></i> Regular User
</span>
}
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Roles</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<div class="flex flex-wrap gap-2">
if user.GetIsAdmin() {
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-blue-900 dark:text-blue-300 inline-flex items-center">
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
</span>
}
for _, role := range user.Roles {
<span class="bg-gray-100 text-gray-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-gray-700 dark:text-gray-300 inline-flex items-center">
<i class="fas fa-user-tag w-3 h-3 mr-1.5"></i> { role.Name }
</span>
}
</div>
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Permissions</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<div class="flex flex-wrap gap-1">
for _, role := range user.Roles {
for _, perm := range role.GetPermissions() {
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
{ perm }
</span>
}
}
</div>
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
if user.TwoFactorEnabled {
<div class="flex flex-col space-y-3">
<div class="flex items-center">
<span class="badge badge-success">
<i class="fas fa-shield-alt mr-1"></i> Enabled
<span class="bg-green-100 text-green-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-green-900 dark:text-green-300 inline-flex items-center">
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
</span>
</div>
<div class="flex flex-wrap gap-2">
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
<i class="fas fa-key mr-1"></i>
<div class="flex flex-wrap gap-2 mt-2">
<a href="/profile/2fa/backup-codes" class="py-2 px-3 text-xs 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 inline-flex items-center shadow-sm">
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
Manage Backup Codes
</a>
<button
type="button"
class="btn-danger btn-sm"
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
onclick="showDisable2FADialog()">
<i class="fas fa-times mr-1"></i>
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
Disable 2FA
</button>
</div>
@@ -176,13 +203,13 @@ templ Profile(ctx context.Context, user db.User) {
} else {
<div class="flex flex-col space-y-3">
<div class="flex items-center">
<span class="badge badge-warning">
<i class="fas fa-shield-alt mr-1"></i> Disabled
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-yellow-900 dark:text-yellow-300 inline-flex items-center">
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
</span>
</div>
<div>
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
<i class="fas fa-lock mr-1"></i>
<div class="mt-2">
<a href="/profile/2fa/setup" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm">
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
Enable 2FA
</a>
</div>
@@ -191,12 +218,12 @@ templ Profile(ctx context.Context, user db.User) {
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
<form
hx-post="/profile/theme"
hx-swap="none"
class="flex items-center space-x-4">
class="flex items-center space-x-5">
<div class="flex items-center">
<input
type="radio"
@@ -206,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "light" || user.Theme == "" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-sun mr-1"></i> Light
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-light" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-sun mr-1.5"></i> Light
</label>
</div>
<div class="flex items-center">
@@ -220,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "dark" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-moon mr-1"></i> Dark
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-dark" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-moon mr-1.5"></i> Dark
</label>
</div>
<div class="flex items-center">
@@ -234,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
checked?={ user.Theme == "system" }
hx-trigger="change"
hx-post="/profile/theme"
class="form-checkbox" />
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
<i class="fas fa-desktop mr-1"></i> System
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
<label for="theme-system" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
<i class="fas fa-desktop mr-1.5"></i> System
</label>
</div>
</form>
@@ -247,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
</div>
<!-- Change Password Card -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-key mr-2 text-primary-500"></i>
Change Password
</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Change Password
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your password to keep your account secure</p>
</div>
</div>
</div>
<div class="card-body">
<div class="p-5">
<form
method="POST"
action="/change-password"
@@ -264,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
hx-swap="innerHTML"
hx-headers='{"X-Profile-Page": "true"}'
hx-indicator="#password-change-indicator"
class="space-y-4"
class="space-y-5"
x-data="{
currentPassword: '',
newPassword: '',
@@ -283,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
<div id="password-result"></div>
<div>
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock mr-1"></i> Current Password
<label for="current-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock mr-1.5"></i> Current Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="current-password"
name="current_password"
x-model="currentPassword"
class="form-input pl-10 w-full"
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 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="••••••••"
required/>
</div>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock-open mr-1"></i> New Password
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-lock-open mr-1.5"></i> New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="new-password"
name="new_password"
x-model="newPassword"
class="form-input pl-10 w-full"
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 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="••••••••"
required/>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-check-double mr-1"></i> Confirm New Password
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
x-model="confirmPassword"
class="form-input pl-10 w-full"
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 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="••••••••"
required/>
</div>
@@ -342,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
class="w-full text-white bg-blue-600 hover:bg-blue-700 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 shadow-sm"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-save mr-2"></i>
Update Password
</span>
<span x-show="loading" class="flex items-center">
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -359,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
</div>
</form>
</div>
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<div class="p-5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<div class="mr-3 text-blue-500 flex-shrink-0">
<i class="fas fa-shield-alt"></i>
</div>
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
</div>
</div>
@@ -369,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>All profile changes are securely logged for your protection</span>
<div class="mt-8">
<div class="p-5 text-sm text-blue-800 rounded-xl bg-blue-50 border border-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:border-blue-800 flex items-start shadow-sm">
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
<i class="fas fa-shield-alt"></i>
</div>
<div>
<h4 class="text-sm font-medium mb-1">Security Information</h4>
<p>All profile changes are securely logged for your protection. For security reasons, you'll be required to enter your password for sensitive changes.</p>
</div>
</div>
</div>
</div>
+98 -126
View File
@@ -1,63 +1,61 @@
package common
templ NameField() {
<div class="space-y-6">
<div class="sm:col-span-4">
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" name="name" id="name" x-model="name" required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Transfer configuration name" />
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="name" id="name" x-model="name" 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 ps-10 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="Transfer configuration name" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
</div>
}
templ FilePatternFields() {
<div class="space-y-6">
<div class="sm:col-span-4">
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
Pattern</label>
<div class="mb-6">
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
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 ps-10 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="*.txt, *.csv" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Glob pattern for files to transfer. Leave empty to transfer all files.
</p>
</div>
<div class="sm:col-span-4">
<label for="output_pattern"
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
<div class="mb-6">
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
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 ps-10 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="${filename}" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
</p>
<div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Pattern for filenames. Available variables:<br />
${`filename`} - Original filename without extension (e.g., "report")<br />
${`ext`} - Original file extension (e.g., "csv")<br />
${`date:format`} - Current date using Go's time format:<br />
• 2006-01-02 → YYYY-MM-DD<br />
• 20060102 → YYYYMMDD<br />
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<span class="block">• ${`filename`} - Original filename without extension (e.g., "report")</span>
<span class="block">• ${`ext`} - Original file extension (e.g., "csv")</span>
<span class="block">• ${`date:format`} - Current date using Go's time format:</span>
<span class="block pl-4">- 2006-01-02 → YYYY-MM-DD</span>
<span class="block pl-4">- 20060102 → YYYYMMDD</span>
<span class="block pl-4">- 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS</span>
<span class="block italic mt-2">Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"</span>
</p>
</div>
</div>
@@ -65,127 +63,102 @@ templ FilePatternFields() {
}
templ ArchiveOptions() {
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive &amp; Delete Options</h3>
<div class="mb-4">
<label for="archive_enabled" class="flex items-center cursor-pointer">
<div class="relative">
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
class="sr-only"
:value="archiveEnabled ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Enable archiving
</div>
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="space-y-6">
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
</label>
</div>
<div class="mb-4" x-show="archiveEnabled">
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
<div x-show="archiveEnabled" class="mt-4">
<label for="archive_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Archive Path</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</span>
<input id="archive_path" name="archive_path" type="text"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Path to archive files"
x-model="archivePath"
/>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
</div>
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
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 ps-10 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="Path to archive files" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Files will be moved here after successful transfer
</p>
</div>
<div class="mb-4">
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
<div class="relative">
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
class="sr-only"
:value="deleteAfterTransfer ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Delete source files after transfer
</div>
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 dark:peer-focus:ring-red-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-red-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
</label>
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
</p>
</div>
<div x-show="deleteAfterTransfer" class="mt-2">
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span>Warning: This will permanently delete the original files</span>
</div>
</div>
</div>
<div class="mb-4">
<label for="skip_processed_files" class="flex items-center cursor-pointer">
<div class="relative">
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
class="sr-only"
:value="skipProcessedFiles ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Skip files that have already been processed
</div>
<div class="flex items-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
class="sr-only peer" :value="skipProcessedFiles ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</span>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Files with the same hash that have been successfully processed before will be skipped
</p>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Files with the same hash that have been successfully processed before will be skipped
</p>
<div class="mb-4">
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
<div class="flex items-center">
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
min="1" max="32" step="1"
x-model="maxConcurrentTransfers"
/>
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<div class="mt-6">
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
</label>
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
</p>
</div>
</div>
</div>
}
templ RcloneFlags() {
<div>
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
<div class="mt-1 relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
<div class="mb-6">
<label for="rclone_flags" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Flags</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
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 ps-10 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="--transfers 4 --checkers 8" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Additional rclone flags for fine-tuning the transfer.
</p>
</div>
}
templ SourceSelection() {
<div class="sm:col-span-3">
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
Type</label>
<div class="mb-6">
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select id="source_type" name="source_type" x-model="sourceType"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
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 ps-10 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="local">Local</option>
<option value="sftp">SFTP</option>
<option value="ftp">FTP</option>
@@ -202,15 +175,14 @@ templ SourceSelection() {
}
templ DestinationSelection() {
<div class="sm:col-span-3">
<label for="destination_type"
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
<div class="mb-6">
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select id="destination_type" name="destination_type" x-model="destinationType"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
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 ps-10 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="local">Local</option>
<option value="sftp">SFTP</option>
<option value="ftp">FTP</option>
+50 -80
View File
@@ -1,109 +1,79 @@
package destination
templ FTPDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
<div class="space-y-6 mt-4">
<div>
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="ftp.example.com"/>
<input type="text" id="dest_host" name="dest_host" x-model="destinationHost" 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 ps-10 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="ftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
</div>
<div class="sm:col-span-4">
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
<div>
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="number"
name="dest_port"
id="dest_port"
x-model="destPort"
required
min="1"
max="65535"
value="21"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="21"/>
<input type="number" id="dest_port" name="dest_port" x-model="destinationPort"
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 ps-10 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="21" min="1" max="65535" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
</div>
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div>
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/destination"/>
<input type="text" id="dest_user" name="dest_user" x-model="destinationUser" 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 ps-10 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="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div>
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="password" id="dest_password" name="dest_password" x-model="destinationPassword" 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 ps-10 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="Enter password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
</label>
</div>
<div class="flex items-start mb-4">
<div class="flex items-center h-5">
<input
id="dest_passive_mode"
name="dest_passive_mode"
type="checkbox"
x-model="destPassiveMode"
:value="destPassiveMode ? 'true' : 'false'"
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
</div>
<div class="ml-3 text-sm">
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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 ps-10 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="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
</div>
</div>
}
+95 -164
View File
@@ -1,175 +1,106 @@
package destination
templ GoogleDriveDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Toggle to use your own Google API credentials instead of the built-in ones.
</p>
</div>
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div x-show="!useBuiltinAuthDestination">
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
</div>
</div>
<div class="mt-4">
<label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Google Drive Folder ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Folder ID to use as the root (leave empty for "My Drive")
</p>
</div>
<div class="mb-6">
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destinationClientId"
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 ps-10 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="Your Google API Client ID" x-bind:required="!useBuiltinAuthDestination" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
</div>
<div class="mt-4">
<label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Shared/Team Drive ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
ID of the Shared Drive / Team Drive to use
</p>
</div>
<div class="mb-6">
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destinationClientSecret"
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 ps-10 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="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDestination" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
</div>
</div>
<div class="mt-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Path within Google Drive (e.g., /backup)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within the Drive where files will be uploaded
</p>
</div>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="/path/in/drive" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
</p>
</div>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div>
<label for="dest_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destinationDriveId"
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 ps-10 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="Specific Google Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared drive. Leave empty to use your personal Google Drive.
</p>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Drive API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div>
<label for="dest_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destinationTeamDrive"
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 ps-10 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="Team Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared team drive. Leave empty for personal drive.
</p>
</div>
</div>
}
+175 -203
View File
@@ -1,216 +1,188 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthDest"
:value="useBuiltinAuthDest ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDestination', value => {
if(value) {
destinationClientId = '';
destinationClientSecret = '';
}
})">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div>
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="mt-4">
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
<div class="mb-6">
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client ID
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destinationClientId"
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 ps-10 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"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client ID from Google Cloud Console
</p>
</div>
<div class="mt-4">
<label for="dest_read_only" class="flex items-center cursor-pointer">
<div class="relative">
<input id="dest_read_only" name="dest_read_only" type="checkbox"
class="sr-only"
x-model="destReadOnly"
:value="destReadOnly ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Only request read-only access to your photos
</p>
</div>
<div class="mb-6">
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client Secret
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destinationClientSecret"
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 ps-10 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"
x-bind:disabled="useBuiltinAuthDest"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="mt-4">
<label for="dest_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Only include photos uploaded after this year
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Only request read-only access to your photos
</p>
<div class="mt-4">
<label for="dest_include_archived" class="flex items-center cursor-pointer">
<div class="relative">
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
class="sr-only"
x-model="destIncludeArchived"
:value="destIncludeArchived ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Include archived photos and videos in media listings
</p>
</div>
<div class="mb-6">
<label for="dest_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
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 ps-10 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="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Only include photos uploaded after this year
</p>
</div>
<div class="mt-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos where files will be uploaded.
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Include archived photos and videos in media listings
</p>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Required</h3>
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex">
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Information</h3>
<div class="mt-1">
<template x-if="useBuiltinAuthDest">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthDest">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Important Note About Google Photos</h3>
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
}
+32 -30
View File
@@ -1,39 +1,41 @@
package destination
package destination
templ LocalDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="space-y-4 mt-4">
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" required
:class="{ 'border-red-500 dark:border-red-700': destinationPathValid === false, 'border-green-500 dark:border-green-700': destinationPathValid === true }"
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 ps-10 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="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
<div x-show="destinationPathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span x-text="destinationPathError"></span>
</div>
</div>
<div x-show="destinationPathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span x-text="destinationPathError || 'Path is valid'"></span>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
required
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Local directory path (e.g., /path/to/destination)"
/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory where files will be saved
</p>
<!-- Path validation message -->
<template x-if="destPathValid !== null">
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(destinationPath, 'dest')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
type="button"
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"
@click="checkPath(destinationPath, 'destination')"
>
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
</svg>
Check Location
</button>
</div>
}
+75 -69
View File
@@ -1,91 +1,97 @@
package destination
templ MinIODestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_endpoint"
id="dest_endpoint"
x-model="destEndpoint"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://minio.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_bucket"
id="dest_bucket"
x-model="destBucket"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="my-bucket"/>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destinationEndpoint" 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 ps-10 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="https://minio.example.com" />
</div>
</div>
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="path/prefix/"/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. If specified, files will be uploaded to this path in the bucket.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your MinIO server including protocol (http:// or https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_access_key"
id="dest_access_key"
x-model="destAccessKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destinationBucket" 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 ps-10 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="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your MinIO bucket (case-sensitive)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destinationAccessKey" 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 ps-10 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="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Access Key for authentication
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destinationSecretKey" 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 ps-10 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="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Secret Key for authentication
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Configuration Note</h3>
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
</div>
<input
type="password"
name="dest_secret_key"
id="dest_secret_key"
x-model="destSecretKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
</div>
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
</div>
</div>
}
@@ -1,56 +1,82 @@
package destination
templ NextCloudDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://nextcloud.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destinationEndpoint" 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 ps-10 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="https://nextcloud.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your NextCloud server including protocol (https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destinationUser" 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 ps-10 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="nextcloud_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username
</p>
</div>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destinationPassword" 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 ps-10 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="NextCloud password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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 ps-10 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="remote.php/dav/files/username/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Tip</h3>
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
</div>
<input
type="password"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
</div>
</div>
}
+75 -69
View File
@@ -1,91 +1,97 @@
package destination
templ S3DestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_bucket"
id="dest_bucket"
x-model="destBucket"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="my-bucket"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_region"
id="dest_region"
x-model="destRegion"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="us-east-1"/>
<input type="text" id="dest_region" name="dest_region" x-model="destinationRegion" 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 ps-10 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="us-east-1" />
</div>
</div>
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="optional/path/prefix/"/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. If specified, files will be uploaded to this path in the bucket.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_access_key"
id="dest_access_key"
x-model="destAccessKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destinationBucket" 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 ps-10 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="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destinationAccessKey" 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 ps-10 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="AKIAIOSFODNN7EXAMPLE" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destinationSecretKey" 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 ps-10 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="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Secret Access Key
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
</div>
<input
type="password"
name="dest_secret_key"
id="dest_secret_key"
x-model="destSecretKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
</div>
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
</div>
</div>
}
+64 -114
View File
@@ -1,147 +1,97 @@
package destination
templ SFTPDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
<div class="space-y-6 mt-4">
<div>
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
aria-describedby="dest_host_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="sftp.example.com"/>
<input type="text" id="dest_host" name="dest_host" x-model="destinationHost" 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 ps-10 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="sftp.example.com" />
</div>
<p id="dest_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Enter the SFTP server hostname or IP address.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<div class="sm:col-span-4">
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
<div>
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="number"
name="dest_port"
id="dest_port"
x-model="destPort"
required
min="1"
max="65535"
value="22"
aria-describedby="dest_port_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="22"/>
<input type="number" id="dest_port" name="dest_port" x-model="destinationPort"
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 ps-10 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="22" min="1" max="65535" />
</div>
<p id="dest_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Default SFTP port is 22.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<div class="sm:col-span-4">
<label for="dest_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div>
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
required
aria-describedby="destination_path_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/files"/>
<input type="text" id="dest_user" name="dest_user" x-model="destinationUser" 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 ps-10 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="username" />
</div>
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the files on the remote server.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
aria-describedby="dest_user_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
<div class="flex items-center mb-4">
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<p id="dest_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Username for SFTP authentication.
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="dest_auth_type"
name="dest_auth_type"
x-model="destAuthType"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
<option value="password">Password</option>
<option value="key_file">SSH Key File</option>
</select>
<div class="flex items-center">
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
<div class="sm:col-span-4" x-show="destAuthType === 'password'">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div x-show="destAuthType === 'password'">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
name="dest_password"
id="dest_password"
x-model="destPassword"
x-bind:required="destAuthType === 'password'"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
<input type="password" id="dest_password" name="dest_password" x-model="destinationPassword"
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 ps-10 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="Enter password" x-bind:required="destAuthType === 'password'" />
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<div class="sm:col-span-4" x-show="destAuthType === 'key_file'">
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
<div x-show="destAuthType === 'key'">
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_key_file"
id="dest_key_file"
x-model="destKeyFile"
x-bind:required="destAuthType === 'key_file'"
aria-describedby="dest_key_file_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/key"/>
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destinationKeyFile"
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 ps-10 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="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
</div>
<p id="dest_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to SSH private key file.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
</div>
<div>
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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 ps-10 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="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
</div>
</div>
}
+84 -83
View File
@@ -1,111 +1,112 @@
package destination
templ SMBDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="server.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_share"
id="dest_share"
x-model="destShare"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="share_name"/>
<input type="text" id="dest_host" name="dest_host" x-model="destinationHost" 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 ps-10 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="192.168.1.100 or server.example.com" />
</div>
</div>
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="path\to\destination"/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Subdirectory within the share.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
IP address or hostname of the SMB/CIFS server
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
<div class="mb-6">
<label for="dest_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_domain"
id="dest_domain"
x-model="destDomain"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="DOMAIN"/>
<input type="text" id="dest_share" name="dest_share" x-model="destinationShare" 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 ps-10 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="Documents" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Windows domain for authentication.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of the shared folder on the server (without leading slashes)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="dest_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="dest_domain" name="dest_domain" x-model="destinationDomain"
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 ps-10 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="WORKGROUP or DOMAIN" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destinationUser" 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 ps-10 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="smb_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Username for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destinationPassword" 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 ps-10 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="SMB password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Password for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="path/to/subfolder" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to a subfolder within the share (Optional). Use forward slashes.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Note</h3>
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
</div>
<input
type="password"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
</div>
</div>
}
+60 -53
View File
@@ -1,75 +1,82 @@
package destination
templ WebDAVDestinationForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="dest_host"
id="dest_host"
x-model="destHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://webdav.example.com/remote.php/webdav/"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="destination_path"
id="destination_path"
x-model="destinationPath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/destination"/>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destinationEndpoint" 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 ps-10 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="https://webdav.example.com" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Relative to the WebDAV URL.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="dest_user"
id="dest_user"
x-model="destUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="dest_user" name="dest_user" x-model="destinationUser" 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 ps-10 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="webdav_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username
</p>
</div>
<div class="sm:col-span-4">
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destinationPassword" 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 ps-10 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="WebDAV password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
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 ps-10 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="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files on the WebDAV server
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Integration Note</h3>
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
</div>
<input
type="password"
name="dest_password"
id="dest_password"
x-model="destPassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="dest_password" :value="destPassword"/>
</div>
</div>
}
+1 -1
View File
@@ -54,7 +54,7 @@ templ ProviderForm(formType string, providers []string, isSource bool) {
id={fmt.Sprintf("%s_provider", formType)}
name={fmt.Sprintf("%s_provider", formType)}
x-model={fmt.Sprintf("%sProvider", formType)}
class="form-select pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
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 ps-10 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="" disabled selected>Select provider type</option>
for _, provider := range providers {
<option value={provider}>{providerDisplayName(provider)}</option>
+50 -80
View File
@@ -1,109 +1,79 @@
package source
templ FTPSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
<div class="space-y-6 mt-4">
<div>
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="ftp.example.com"/>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" 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 ps-10 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="ftp.example.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
</div>
<div class="sm:col-span-4">
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
<div>
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="number"
name="source_port"
id="source_port"
x-model="sourcePort"
required
min="1"
max="65535"
value="21"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="21"/>
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
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 ps-10 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="21" min="1" max="65535" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div>
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/files"/>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" 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 ps-10 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="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div>
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" 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 ps-10 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="Enter password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
</label>
</div>
<div class="flex items-start mb-4">
<div class="flex items-center h-5">
<input
id="source_passive_mode"
name="source_passive_mode"
type="checkbox"
x-model="sourcePassiveMode"
:value="sourcePassiveMode ? 'true' : 'false'"
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
</div>
<div class="ml-3 text-sm">
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" 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 ps-10 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="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
</div>
</div>
}
+95 -164
View File
@@ -1,175 +1,106 @@
package source
templ GoogleDriveSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Toggle to use your own Google API credentials instead of the built-in ones.
</p>
</div>
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Drive OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div x-show="!useBuiltinAuthSource">
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
</div>
</div>
<div class="mt-4">
<label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Google Drive Folder ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Folder ID to use as the root (leave empty for "My Drive")
</p>
</div>
<div class="mb-6">
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
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 ps-10 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="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
</div>
<div class="mt-4">
<label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Shared/Team Drive ID (optional)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
ID of the Shared Drive / Team Drive to use
</p>
</div>
<div class="mb-6">
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
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 ps-10 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="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
</div>
</div>
<div class="mt-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Path within Google Drive (e.g., /backup)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within the Drive from which files will be transferred
</p>
</div>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="/path/in/drive" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
</p>
</div>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div>
<label for="source_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
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 ps-10 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="Specific Google Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared drive. Leave empty to use your personal Google Drive.
</p>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Drive with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Drive API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div>
<label for="source_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
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 ps-10 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="Team Drive ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
For accessing a shared team drive. Leave empty for personal drive.
</p>
</div>
</div>
}
+175 -203
View File
@@ -1,216 +1,188 @@
package source
templ GooglePhotosSourceForm() {
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="mb-6">
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
<div class="relative">
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
class="sr-only"
x-model="useBuiltinAuthSource"
:value="useBuiltinAuthSource ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Use rclone's built-in Google authentication (recommended)
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
if(value) {
sourceClientId = '';
sourceClientSecret = '';
}
})">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div>
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client ID from Google Cloud Console
</p>
</div>
<div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
</label>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Simple one-click authentication using rclone's shared credentials
</p>
</div>
<div class="mt-4">
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Google Client Secret
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
<div class="mb-6">
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client ID
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
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 ps-10 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"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client ID" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client ID from Google Cloud Console
</p>
</div>
<div class="mt-4">
<label for="source_read_only" class="flex items-center cursor-pointer">
<div class="relative">
<input id="source_read_only" name="source_read_only" type="checkbox"
class="sr-only"
x-model="sourceReadOnly"
:value="sourceReadOnly ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Read-only mode
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Only request read-only access to your photos
</p>
</div>
<div class="mb-6">
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Google Client Secret
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
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 ps-10 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"
x-bind:disabled="useBuiltinAuthSource"
placeholder="Google Photos OAuth Client Secret" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Client Secret from Google Cloud Console
</p>
</div>
</div>
<div class="mt-4">
<label for="source_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Only include photos uploaded after this year
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Only request read-only access to your photos
</p>
<div class="mt-4">
<label for="source_include_archived" class="flex items-center cursor-pointer">
<div class="relative">
<input id="source_include_archived" name="source_include_archived" type="checkbox"
class="sr-only"
x-model="sourceIncludeArchived"
:value="sourceIncludeArchived ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
Include archived media
</div>
</label>
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
Include archived photos and videos in media listings
</p>
</div>
<div class="mb-6">
<label for="source_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
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 ps-10 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="Only include photos after this year (default: 2000)" min="1900" max="2100" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Only include photos uploaded after this year
</p>
</div>
<div class="mt-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path within Google Photos to download files from
</p>
</div>
<div class="flex items-center mb-6">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
</label>
</div>
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
Include archived photos and videos in media listings
</p>
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="Album path (e.g., album/my-photos)" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path within Google Photos to download files from
</p>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Required</h3>
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
<ol class="list-decimal list-inside mt-2 space-y-1">
<li>Go to the configurations page</li>
<li>Click the "Authenticate" button for this configuration</li>
<li>You'll be redirected to Google to grant access permissions</li>
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
</ol>
</div>
</div>
</div>
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex">
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Authentication Information</h3>
<div class="mt-1">
<template x-if="useBuiltinAuthSource">
<div>
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>No need to create your own Google API credentials</li>
<li>Authentication happens through Google's secure OAuth flow</li>
<li>Automatic redirection back to GoMFT after authentication</li>
</ul>
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
</p>
</div>
</template>
<template x-if="!useBuiltinAuthSource">
<div>
<p>To use Google Photos with your own credentials:</p>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
<li>Create a project and enable the Google Photos API</li>
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
</ol>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Important Note About Google Photos</h3>
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
</div>
</div>
</div>
</div>
}
+31 -29
View File
@@ -1,39 +1,41 @@
package source
templ LocalSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
<div class="space-y-4 mt-4">
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
:class="{ 'border-red-500 dark:border-red-700': sourcePathValid === false, 'border-green-500 dark:border-green-700': sourcePathValid === true }"
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 ps-10 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="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
<div x-show="sourcePathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span x-text="sourcePathError"></span>
</div>
</div>
<div x-show="sourcePathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span x-text="sourcePathError || 'Path is valid'"></span>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
required
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Local directory path (e.g., /path/to/files)"
/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Full path to the local directory containing your files
</p>
<!-- Path validation message -->
<template x-if="sourcePathValid !== null">
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
</template>
</div>
<button
type="button"
class="btn-secondary h-10 whitespace-nowrap"
@click="checkPath(sourcePath, 'source')"
>
<i class="fas fa-check-circle mr-2"></i>
Check Location
</button>
type="button"
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"
@click="checkPath(sourcePath, 'source')"
>
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
</svg>
Check Location
</button>
</div>
}
+75 -69
View File
@@ -1,91 +1,97 @@
package source
templ MinIOSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_endpoint"
id="source_endpoint"
x-model="sourceEndpoint"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://minio.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_bucket"
id="source_bucket"
x-model="sourceBucket"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="my-bucket"/>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" 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 ps-10 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="https://minio.example.com" />
</div>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="path/prefix/"/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. If specified, only files in this path will be processed.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your MinIO server including protocol (http:// or https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_access_key"
id="source_access_key"
x-model="sourceAccessKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" 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 ps-10 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="my-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your MinIO bucket (case-sensitive)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" 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 ps-10 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="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Access Key for authentication
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" 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 ps-10 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="minioadmin" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your MinIO Secret Key for authentication
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Configuration Note</h3>
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
</div>
<input
type="password"
name="source_secret_key"
id="source_secret_key"
x-model="sourceSecretKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
</div>
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
</div>
</div>
}
+60 -53
View File
@@ -1,75 +1,82 @@
package source
templ NextCloudSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://nextcloud.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/remote.php/dav/files/username/path/to/files"/>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" 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 ps-10 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="https://nextcloud.example.com" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. If left empty, root directory will be used.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your NextCloud server including protocol (https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" 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 ps-10 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="nextcloud_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username
</p>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" 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 ps-10 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="NextCloud password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" 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 ps-10 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="remote.php/dav/files/username/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Tip</h3>
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
</div>
<input
type="password"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
</div>
</div>
}
+67 -71
View File
@@ -1,100 +1,96 @@
package source
templ S3SourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fab fa-aws text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_bucket"
id="source_bucket"
x-model="sourceBucket"
required
aria-describedby="source_bucket_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="my-bucket"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
</div>
<p id="source_bucket_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Enter your S3 bucket name.
</div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" 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 ps-10 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="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_region"
id="source_region"
x-model="sourceRegion"
required
aria-describedby="source_region_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="us-west-2"/>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" 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 ps-10 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="my-bucket" />
</div>
<p id="source_region_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
AWS region for the S3 bucket (e.g., us-west-2).
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
aria-describedby="source_path_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="path/to/files/"/>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" 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 ps-10 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="AKIAIOSFODNN7EXAMPLE" />
</div>
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional path prefix within the bucket (e.g., 'path/to/files/').
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<div class="sm:col-span-4">
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key ID</label>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_access_key"
id="source_access_key"
x-model="sourceAccessKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="AKIAIOSFODNN7EXAMPLE"/>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" 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 ps-10 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="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Secret Access Key
</p>
</div>
<div class="sm:col-span-4">
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Access Key</label>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
</div>
<input
type="password"
name="source_secret_key"
id="source_secret_key"
x-model="sourceSecretKey"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Your secret access key"/>
</div>
</div>
</div>
+64 -114
View File
@@ -1,147 +1,97 @@
package source
templ SFTPSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
<div class="space-y-6 mt-4">
<div>
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
aria-describedby="source_host_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="sftp.example.com"/>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" 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 ps-10 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="sftp.example.com" />
</div>
<p id="source_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Enter the SFTP server hostname or IP address.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<div class="sm:col-span-4">
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
<div>
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="number"
name="source_port"
id="source_port"
x-model="sourcePort"
required
min="1"
max="65535"
value="22"
aria-describedby="source_port_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="22"/>
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
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 ps-10 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="22" min="1" max="65535" />
</div>
<p id="source_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Default SFTP port is 22.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div>
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
required
aria-describedby="source_path_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/files"/>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" 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 ps-10 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="username" />
</div>
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to the files on the remote server.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
aria-describedby="source_user_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
<div class="flex items-center mb-4">
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<p id="source_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Username for SFTP authentication.
</p>
</div>
<div class="sm:col-span-4">
<label for="source_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="source_auth_type"
name="source_auth_type"
x-model="sourceAuthType"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
<option value="password">Password</option>
<option value="key_file">SSH Key File</option>
</select>
<div class="flex items-center">
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
<div class="sm:col-span-4" x-show="sourceAuthType === 'password'">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div x-show="sourceAuthType === 'password'">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
name="source_password"
id="source_password"
x-model="sourcePassword"
x-bind:required="sourceAuthType === 'password'"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
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 ps-10 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="Enter password" x-bind:required="sourceAuthType === 'password'" />
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<div class="sm:col-span-4" x-show="sourceAuthType === 'key_file'">
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
<div x-show="sourceAuthType === 'key'">
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_key_file"
id="source_key_file"
x-model="sourceKeyFile"
x-bind:required="sourceAuthType === 'key_file'"
aria-describedby="source_key_file_help"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/key"/>
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
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 ps-10 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="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
</div>
<p id="source_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Absolute path to SSH private key file.
</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
</div>
<div>
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" 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 ps-10 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="/remote/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
</div>
</div>
}
+84 -83
View File
@@ -1,111 +1,112 @@
package source
templ SMBSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="server.example.com"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_share"
id="source_share"
x-model="sourceShare"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="share_name"/>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" 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 ps-10 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="192.168.1.100 or server.example.com" />
</div>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="path\to\files"/>
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Subdirectory within the share.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
IP address or hostname of the SMB/CIFS server
</p>
</div>
<div class="sm:col-span-4">
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
<div class="mb-6">
<label for="source_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_domain"
id="source_domain"
x-model="sourceDomain"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="DOMAIN"/>
<input type="text" id="source_share" name="source_share" x-model="sourceShare" 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 ps-10 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="Documents" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Windows domain for authentication.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of the shared folder on the server (without leading slashes)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="source_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
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 ps-10 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="WORKGROUP or DOMAIN" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
</p>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" 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 ps-10 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="smb_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Username for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" 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 ps-10 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="SMB password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Password for SMB/CIFS authentication
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="path/to/subfolder" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to a subfolder within the share (Optional). Use forward slashes.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Connection Note</h3>
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
</div>
<input
type="password"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
</div>
</div>
}
+60 -53
View File
@@ -1,75 +1,82 @@
package source
templ WebDAVSourceForm() {
<div class="sm:col-span-6 space-y-6">
<div class="sm:col-span-4">
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="text"
name="source_host"
id="source_host"
x-model="sourceHost"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://webdav.example.com/remote.php/webdav/"/>
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
</div>
</div>
<div class="sm:col-span-4">
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_path"
id="source_path"
x-model="sourcePath"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="/path/to/files"/>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" 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 ps-10 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="https://webdav.example.com" />
</div>
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Optional. Relative to the WebDAV URL.
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<div class="sm:col-span-4">
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
name="source_user"
id="source_user"
x-model="sourceUser"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" 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 ps-10 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="webdav_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username
</p>
</div>
<div class="sm:col-span-4">
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" 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 ps-10 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="WebDAV password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
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 ps-10 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="/path/to/files" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files on the WebDAV server
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Integration Note</h3>
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
</div>
<input
type="password"
name="source_password"
id="source_password"
x-model="sourcePassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="Password"/>
</div>
<input type="hidden" name="source_password" :value="sourcePassword"/>
</div>
</div>
}
+152 -82
View File
@@ -6,98 +6,168 @@ import (
templ Register(ctx context.Context, errorMessage string) {
@LayoutWithContext("Register", ctx) {
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new user account
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Complete the form below to create a new user
</p>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-user-plus w-6 h-6 mr-2 text-blue-500"></i>
Create Account
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Complete the form below to create a new user account
</p>
<!-- Registration Card -->
<div class="max-w-md mx-auto">
if errorMessage != "" {
<div class="mt-4">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{ errorMessage }</span>
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ errorMessage }</span>
</div>
</div>
}
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email" class="sr-only">Email address</label>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input rounded-t-md"
placeholder="Email address"/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input"
placeholder="Password"/>
</div>
<div>
<label for="confirm-password" class="sr-only">Confirm Password</label>
<input
id="confirm-password"
name="confirm_password"
type="password"
required
x-model="confirmPassword"
class="form-input rounded-b-md"
placeholder="Confirm Password"/>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Registration
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
</div>
</div>
</div>
<div class="p-6">
<form
hx-post="/register"
hx-target="body"
class="space-y-4"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Email Address
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-envelope text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="email"
id="email"
name="email"
x-model="email"
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 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="your.email@example.com"
required
/>
</div>
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="password"
name="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 pl-10 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="••••••••"
required
/>
</div>
</div>
<div>
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Confirm Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
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 pl-10 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="••••••••"
required
/>
</div>
<template x-if="confirmPassword && password !== confirmPassword">
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
<i class="fas fa-exclamation-circle mr-1"></i>
Passwords do not match
</p>
</template>
</div>
<button
type="submit"
class="w-full 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="!validate() || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-user-plus mr-2"></i>
Create Account
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Already have an account?
<a href="/login" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
Sign in
</a>
</p>
</div>
</div>
</div>
</div>
<!-- Help Notice -->
<div class="mt-6 max-w-md mx-auto">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center"
x-bind:disabled="!validate() || loading">
<span x-show="!loading">Create User</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
<h4 class="text-sm font-medium mb-1">Account Information</h4>
<p>Creating an account will allow you to manage file transfers and configurations. Your email address will only be used for authentication and important notifications related to your transfers.</p>
</div>
</form>
<div class="text-center">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</div>
</div>
</div>
+457
View File
@@ -0,0 +1,457 @@
package components
import (
"fmt"
"context"
)
type NotificationService struct {
ID uint
Name string
Type string // "email", "slack", "webhook", etc.
IsEnabled bool
Config map[string]string
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
type SettingsData struct {
NotificationServices []NotificationService
ErrorMessage string
SuccessMessage string
}
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">
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
Application Settings
</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure global settings for your GoMFT instance.
</p>
</div>
<!-- Success Message -->
if data.SuccessMessage != "" {
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900 dark:text-green-400" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>{ data.SuccessMessage }</span>
</div>
</div>
}
<!-- Error Message -->
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
<!-- Tabs -->
<div class="mb-4 border-b border-gray-200 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" id="settingsTabs" role="tablist">
<li class="mr-2" role="presentation">
<button class="inline-block p-4 border-b-2 rounded-t-lg border-blue-600 text-blue-600 dark:text-blue-500 dark:border-blue-500" id="notifications-tab" data-tabs-target="#notifications" type="button" role="tab" aria-controls="notifications" aria-selected="true">
<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>
</ul>
</div>
<!-- Tab Content -->
<div id="settingsTabContent">
<!-- Notifications Tab -->
<div class="block p-4 rounded-lg bg-white dark:bg-gray-800" id="notifications" role="tabpanel" aria-labelledby="notifications-tab">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Notification Services</h2>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
data-modal-target="add-notification-modal"
data-modal-toggle="add-notification-modal"
>
<i class="fas fa-plus mr-2"></i>Add Service
</button>
</div>
if len(data.NotificationServices) == 0 {
<div class="text-center py-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Add your first notification service to start receiving alerts about jobs and system events.</p>
<button
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
data-modal-target="add-notification-modal"
data-modal-toggle="add-notification-modal"
>
<i class="fas fa-plus mr-2"></i>Add Notification Service
</button>
</div>
} else {
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
for _, service := range data.NotificationServices {
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "slack" {
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 dark:bg-purple-900 dark:text-purple-400">
<i class="fab fa-slack"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400">
<i class="fas fa-code"></i>
</div>
} else {
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400">
<i class="fas fa-bell"></i>
</div>
}
<h3 class="ml-3 text-lg font-semibold text-gray-900 dark:text-white">{ service.Name }</h3>
</div>
<div class="flex">
<div class="inline-flex">
<button
type="button"
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
data-modal-target="edit-notification-modal"
data-modal-toggle="edit-notification-modal"
data-service-id={ fmt.Sprint(service.ID) }
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
hx-delete={ "/settings/notifications/" + fmt.Sprint(service.ID) }
hx-confirm="Are you sure you want to delete this notification service? This cannot be undone."
hx-target="body"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
<div class="flex items-center mb-2">
<span class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{ service.Description }</p>
if service.Type == "webhook" {
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Webhook Configuration</h4>
<div class="grid grid-cols-2 gap-2">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Secret Key:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.SecretKey == "" {
None
} else {
<i class="fas fa-check-circle text-green-500"></i> Configured
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Custom Payload:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.PayloadTemplate == "" {
Default
} else {
<i class="fas fa-check-circle text-green-500"></i> Custom
}
</span>
</div>
</div>
</div>
}
</div>
}
</div>
}
</div>
<!-- General Tab -->
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="general" role="tabpanel" aria-labelledby="general-tab">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">General Settings</h2>
<form class="space-y-6" action="/settings/general" method="POST">
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="app_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Name</label>
<input type="text" id="app_name" name="app_name" 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" value="GoMFT" />
</div>
<div>
<label for="base_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base URL</label>
<input type="url" id="base_url" name="base_url" 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" value="http://localhost:8080" />
</div>
</div>
<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">Save Settings</button>
</form>
</div>
<!-- Security Tab -->
<div class="hidden p-4 rounded-lg bg-white dark:bg-gray-800" id="security" role="tabpanel" aria-labelledby="security-tab">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Security Settings</h2>
<form class="space-y-6" action="/settings/security" method="POST">
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="session_timeout" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Session Timeout (minutes)</label>
<input type="number" id="session_timeout" name="session_timeout" 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" value="60" min="5" max="1440" />
</div>
<div>
<label for="password_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password Policy</label>
<select id="password_policy" name="password_policy" 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="standard">Standard (8+ chars, 1 uppercase, 1 number)</option>
<option value="strong">Strong (12+ chars, uppercase, number, symbol)</option>
<option value="very_strong">Very Strong (16+ chars, uppercase, number, symbol)</option>
</select>
</div>
</div>
<div class="flex items-start mb-6">
<div class="flex items-center h-5">
<input id="enable_2fa" name="enable_2fa" 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" checked />
</div>
<div class="ml-3 text-sm">
<label for="enable_2fa" class="font-medium text-gray-900 dark:text-white">Enable Two-Factor Authentication</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Require 2FA for all admin users</p>
</div>
</div>
<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">Save Settings</button>
</form>
</div>
</div>
</div>
<!-- Add Notification Service Modal -->
<div id="add-notification-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative w-full max-w-2xl max-h-full">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Add Notification Service
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="add-notification-modal">
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-6 space-y-6">
<form id="add-notification-form" hx-post="/settings/notifications" hx-target="body">
<div class="mb-6">
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
<select id="notification_type" name="type" 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="">Select a type</option>
<option value="email">Email</option>
<option value="slack">Slack</option>
<option value="webhook">Webhook</option>
</select>
</div>
<div class="mb-6">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input type="text" id="notification_name" name="name" 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="My Email Notification" required />
</div>
<div class="mb-6">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea id="notification_description" name="description" rows="3" 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="Description for this notification service"></textarea>
</div>
<!-- Dynamic fields based on notification type -->
<div id="email_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host" 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="smtp.example.com" />
</div>
<div class="mb-6">
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port" 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="587" />
</div>
<div class="mb-6">
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username" 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" />
</div>
<div class="mb-6">
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_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" />
</div>
<div class="mb-6">
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
<input type="email" id="from_email" name="from_email" 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="notifications@example.com" />
</div>
</div>
<div id="slack_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
<input type="url" id="webhook_url" name="webhook_url" 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="https://hooks.slack.com/services/..." />
</div>
<div class="mb-6">
<label for="channel" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Channel</label>
<input type="text" id="channel" name="channel" 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="#general" />
</div>
</div>
<div id="webhook_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
<input type="url" id="webhook_url" name="webhook_url" 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="https://api.example.com/webhook" />
</div>
<div class="mb-6">
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
<select id="method" name="method" 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="POST">POST</option>
<option value="PUT">PUT</option>
</select>
</div>
<div class="mb-6">
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
<textarea id="headers" name="headers" rows="3" 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='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
</div>
<div class="mb-6">
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
<textarea id="payload_template" name="payload_template" rows="5" 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='{"job_id": "{{job.id}}", "status": "{{job.status}}", "message": "{{job.message}}", "timestamp": "{{job.timestamp}}"}'></textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use {`job.field`} as placeholders for job data</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
<input id="trigger_job_start" name="trigger_job_start" 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" checked />
<label for="trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
<input id="trigger_job_complete" name="trigger_job_complete" 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" checked />
<label for="trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
<input id="trigger_job_error" name="trigger_job_error" 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" checked />
<label for="trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
<input type="text" id="secret_key" name="secret_key" 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="Optional signature verification key" />
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
</div>
<div class="mb-6">
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
<select id="retry_policy" name="retry_policy" 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="none">No retries</option>
<option value="simple" selected>Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
</select>
</div>
</div>
<div class="flex items-start mb-6">
<div class="flex items-center h-5">
<input id="is_enabled" name="is_enabled" 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" checked />
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
</div>
</div>
</form>
</div>
<div class="flex items-center justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="add-notification-modal" 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">Cancel</button>
<button form="add-notification-form" 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">Add Service</button>
</div>
</div>
</div>
</div>
}
}
script toggleNotificationFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
const allFields = document.querySelectorAll('.notification-fields');
typeSelector.addEventListener('change', function() {
// Hide all fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show the selected type's fields
const selectedType = this.value;
if (selectedType) {
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
}
});
});
}
+173 -143
View File
@@ -4,167 +4,197 @@ import "context"
import "strings"
type TwoFactorBackupCodesData struct {
BackupCodes []string
ErrorMessage string
SuccessMessage string
BackupCodes []string
ErrorMessage string
SuccessMessage string
}
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
<div class="text-center mb-6">
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-key w-6 h-6 mr-2 text-blue-500"></i>
Two-Factor Authentication Backup Codes
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
These codes can be used to login if you lose access to your authenticator app
</p>
if data.ErrorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
if data.SuccessMessage != "" {
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
<!-- Important warning alert -->
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2"></i>
<span class="font-bold">{ data.SuccessMessage }</span>
</div>
</div>
} else {
<!-- Success alert -->
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900/50 dark:text-green-400 border border-green-200 dark:border-green-800" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle flex-shrink-0 mr-2"></i>
<span>{ data.SuccessMessage }</span>
</div>
</div>
}
}
<div class="space-y-8">
if len(data.BackupCodes) > 0 {
<!-- Backup codes card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Your Backup Codes
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Store these securely for account recovery</p>
</div>
</div>
</div>
}
if data.SuccessMessage != "" {
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
// Show important messages with a different style and icon
<div class="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
</div>
</div>
} else {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="block sm:inline">{ data.SuccessMessage }</span>
</div>
</div>
}
}
<div class="space-y-8">
if len(data.BackupCodes) > 0 {
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
<div class="flex items-start">
<div class="flex-shrink-0 mt-0.5">
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
</div>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
<p><strong>These codes are your backup method to access your account.</strong></p>
<ul class="list-disc pl-5 space-y-1">
<li>Each code can only be used once.</li>
<li>Store these codes in a secure password manager.</li>
<li>These codes allow access to your account - keep them safe!</li>
<li>For security, you can only view codes immediately after generating them.</li>
</ul>
</div>
</div>
<div class="p-6">
<!-- Security Alert -->
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
<div class="flex items-start">
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">Security information</h4>
<p class="font-semibold mb-2">These codes are your backup method to access your account.</p>
<ul class="list-disc pl-5 space-y-1">
<li>Each code can only be used once.</li>
<li>Store these codes in a secure password manager.</li>
<li>These codes allow access to your account - keep them safe!</li>
<li>For security, you can only view codes immediately after generating them.</li>
</ul>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
for _, code := range data.BackupCodes {
if strings.Contains(code, "REDACTED") {
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
{ code }
</div>
} else {
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
{ code }
</div>
}
}
</div>
<div class="flex justify-center space-x-4 mt-6">
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
<button
class="btn-secondary"
onclick="downloadBackupCodes(this)">
<i class="fas fa-download mr-2"></i>
Download Backup Codes
</button>
}
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
<button type="submit" class="btn-primary">
<i class="fas fa-sync-alt mr-2"></i>
Generate New Codes
</button>
</form>
</div>
<script>
function downloadBackupCodes(button) {
// Get backup codes from the displayed elements that don't contain "REDACTED"
const codes = Array.from(
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
).map(el => el.textContent.trim());
// Create content for the file
const content =
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
"=====================================\n\n" +
codes.join("\n") +
"\n\n" +
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
"SECURITY WARNINGS:\n" +
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
"* Each code can only be used once.\n" +
"* Keep these codes in a secure location like a password manager.\n" +
"* Treat these codes with the same security as your password.";
// Create blob and download link
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '2fa-backup-codes-secure.txt';
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
</script>
</div>
} else {
<div class="text-center py-8">
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
</div>
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
<form method="POST" action="/profile/2fa/regenerate-codes">
<button type="submit" class="btn-primary">
<!-- Backup Codes Grid -->
<div class="grid grid-cols-2 gap-4 mb-6">
for _, code := range data.BackupCodes {
if strings.Contains(code, "REDACTED") {
<div class="p-2.5 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm text-center text-gray-500 dark:text-gray-400">
{ code }
</div>
} else {
<div class="p-2.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded font-mono text-sm text-center font-bold text-blue-900 dark:text-blue-300">
{ code }
</div>
}
}
</div>
<!-- Buttons -->
<div class="flex flex-col sm:flex-row justify-center gap-3 mt-6">
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
<button
class="inline-flex items-center justify-center px-5 py-2.5 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg focus:outline-none 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"
onclick="downloadBackupCodes(this)">
<i class="fas fa-download mr-2"></i>
Download Backup Codes
</button>
}
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
<button type="submit" class="inline-flex items-center justify-center w-full sm:w-auto 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">
<i class="fas fa-sync-alt mr-2"></i>
Generate Backup Codes
Generate New Codes
</button>
</form>
</div>
}
</div>
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex justify-between items-center">
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to Profile
</a>
</div>
</div>
<script>
function downloadBackupCodes(button) {
// Get backup codes from the displayed elements that don't contain "REDACTED"
const codes = Array.from(
document.querySelectorAll('.bg-blue-50.dark\\:bg-blue-900\\/30')
).map(el => el.textContent.trim());
// Create content for the file
const content =
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
"=====================================\n\n" +
codes.join("\n") +
"\n\n" +
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
"SECURITY WARNINGS:\n" +
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
"* Each code can only be used once.\n" +
"* Keep these codes in a secure location like a password manager.\n" +
"* Treat these codes with the same security as your password.";
// Create blob and download link
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '2fa-backup-codes-secure.txt';
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
</script>
} else {
<!-- No backup codes state -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center 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-key text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Backup Codes Available</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">You don't have any backup codes. Generate new ones for account recovery.</p>
<form method="POST" action="/profile/2fa/regenerate-codes">
<button type="submit" 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-sync-alt w-4 h-4 mr-2"></i>
Generate Backup Codes
</button>
</form>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-6">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">Security Information</h4>
<p>Backup codes are single-use passwords that allow you to sign in to your account when you don't have access to your authenticator app. Keep these codes in a secure place, such as a password manager or a secure file.</p>
</div>
</div>
</div>
<!-- Back to Profile Link -->
<div class="mt-6 flex justify-start">
<a href="/profile" class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
<i class="fas fa-arrow-left mr-2"></i>
Back to Profile
</a>
</div>
</div>
}
}
+102 -61
View File
@@ -8,76 +8,117 @@ type BackupCodeVerifyData struct {
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
@LayoutWithContext("Backup Code Verification", ctx) {
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-key w-6 h-6 mr-2 text-blue-500"></i>
Backup Code Verification
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Enter one of your backup codes to verify your identity
</p>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
if data.ErrorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
<!-- Verification Card -->
<div class="max-w-md mx-auto">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-yellow-600 dark:text-yellow-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Backup Code Authentication
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Alternative to authenticator app</p>
</div>
</div>
}
<form
method="POST"
action="/login/verify"
class="space-y-6"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Backup Code
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<div class="p-6">
<form
method="POST"
action="/login/verify"
class="space-y-4"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Backup Code
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
id="code"
name="code"
x-model="code"
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 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="Enter backup code"
required
autocomplete="one-time-code"/>
</div>
<input
type="text"
id="code"
name="code"
x-model="code"
class="form-input pl-10 w-full"
placeholder="Enter backup code"
required
autocomplete="one-time-code"/>
<p class="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-start">
<i class="fas fa-exclamation-triangle mr-1.5 mt-0.5 flex-shrink-0"></i>
<span>Remember that each backup code can only be used once!</span>
</p>
</div>
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
Remember that each backup code can only be used once!
<button
type="submit"
class="w-full 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="!code.trim() || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-shield-alt mr-2"></i>
Verify
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
<div class="text-center">
<a href="/login/verify" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
Use authenticator app instead
</a>
</div>
</form>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
<p>
Backup codes were provided when you set up two-factor authentication. Each code can only be used once.
</p>
</div>
</div>
</div>
</div>
<button
type="submit"
class="btn-primary w-full"
x-bind:disabled="!code.trim() || loading">
<span x-show="!loading">Verify</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
<div class="text-center">
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
Use authenticator app instead
</a>
</div>
</form>
<!-- Help Notice -->
<div class="mt-6 max-w-md mx-auto">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">Security Information</h4>
<p>Backup codes provide an alternative way to verify your identity when you don't have access to your authenticator app. Each code can only be used once for security reasons.</p>
</div>
</div>
</div>
</div>
+190 -125
View File
@@ -11,137 +11,202 @@ type TwoFactorSetupData struct {
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</p>
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
Two-Factor Authentication Setup
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Enhance your account security with 2FA by setting up a verification app
</p>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
if data.ErrorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- QR Code Section -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-qrcode text-blue-600 dark:text-blue-300"></i>
</div>
</div>
}
<div class="space-y-8">
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="flex justify-center mb-4">
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
</div>
<div class="text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
Can't scan the QR code? Use this code instead:
</p>
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
{ data.Secret }
</code>
</div>
</div>
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
<form
method="POST"
action="/profile/2fa/verify"
class="space-y-4"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Enter the 6-digit code from your authenticator app
</label>
<input
type="text"
id="code"
name="code"
x-model="code"
class="form-input block w-full"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
required/>
</div>
<button
type="submit"
class="btn-primary w-full"
x-bind:disabled="code.length !== 6 || loading">
<span x-show="!loading">Verify and Enable 2FA</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
</form>
</div>
if len(data.BackupCodes) > 0 {
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
</p>
<div class="grid grid-cols-2 gap-4 mb-4">
for _, code := range data.BackupCodes {
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
{ code }
</div>
}
</div>
<div class="text-center">
<button
class="btn-secondary"
onclick="downloadBackupCodes(this)">
<i class="fas fa-download mr-2"></i>
Download Backup Codes
</button>
<script>
function downloadBackupCodes(button) {
// Get backup codes from the displayed elements
const codes = Array.from(
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
).map(el => el.textContent.trim());
// Create content for the file
const content =
"2FA Backup Codes - Keep these safe!\n" +
"=====================================\n\n" +
codes.join("\n") +
"\n\n" +
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
"Each code can only be used once. Keep these codes safe and secure.";
// Create blob and download link
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '2fa-backup-codes.txt';
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
</script>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
1. Scan QR Code
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Scan this QR code with your authenticator app</p>
</div>
</div>
</div>
<div class="p-6 flex flex-col items-center">
<div class="mb-6 bg-white p-2 rounded-lg border border-gray-200 dark:border-gray-700">
<img src={ data.QRCodeURL } alt="QR Code" class="h-48 w-48"/>
</div>
<div class="w-full text-center mb-2">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
Can't scan the QR code? Use this code instead:
</p>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 p-3 font-mono text-sm text-gray-800 dark:text-gray-200 break-all">
{ data.Secret }
</div>
</div>
</div>
</div>
<!-- Verification Section -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-check-circle text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
2. Verify Setup
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Enter the code from your authenticator app</p>
</div>
</div>
</div>
<div class="p-6">
<form
method="POST"
action="/profile/2fa/verify"
class="space-y-4"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Enter the 6-digit code from your authenticator app
</label>
<input
type="text"
id="code"
name="code"
x-model="code"
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"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
placeholder="Enter 6-digit code"
required/>
</div>
<button
type="submit"
class="w-full 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="code.length !== 6 || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-shield-alt mr-2"></i>
Verify and Enable 2FA
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
</form>
</div>
</div>
</div>
if len(data.BackupCodes) > 0 {
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
3. Save Backup Codes
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Store these backup codes in a safe place</p>
</div>
</div>
</div>
<div class="p-6">
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800" role="alert">
<div class="flex">
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 mt-1"></i>
<div>
<span class="font-medium">Important:</span> These codes can be used to access your account if you lose your authenticator device. Each code can only be used once. Keep these codes safe and secure.
</div>
</div>
}
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
for _, code := range data.BackupCodes {
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 font-mono text-sm text-center text-gray-800 dark:text-gray-200">
{ code }
</div>
}
</div>
<div class="text-center">
<button
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 inline-flex items-center"
onclick="downloadBackupCodes(this)">
<i class="fas fa-download mr-2"></i>
Download Backup Codes
</button>
<script>
function downloadBackupCodes(button) {
// Get backup codes from the displayed elements
const codes = Array.from(
document.querySelectorAll('.bg-gray-50.dark\\:bg-gray-700.font-mono')
).map(el => el.textContent.trim());
// Create content for the file
const content =
"2FA Backup Codes - Keep these safe!\n" +
"=====================================\n\n" +
codes.join("\n") +
"\n\n" +
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
"Each code can only be used once. Keep these codes safe and secure.";
// Create blob and download link
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '2fa-backup-codes.txt';
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
</script>
</div>
</div>
</div>
}
<!-- Help Notice -->
<div class="mt-6">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">About Two-Factor Authentication</h4>
<p>After enabling 2FA, you'll need to enter a verification code from your authenticator app when signing in. This adds an extra layer of security to your account even if your password is compromised.</p>
</div>
</div>
</div>
+99 -66
View File
@@ -8,81 +8,114 @@ type TwoFactorVerifyData struct {
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
@LayoutWithContext("Two-Factor Authentication", ctx) {
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from your authenticator app</p>
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
Two-Factor Authentication
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Enter the verification code from your authenticator app to continue
</p>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
if data.ErrorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
<!-- Verification Card -->
<div class="max-w-md mx-auto">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Authentication Verification
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Secure your account with 2FA</p>
</div>
</div>
}
<form
method="POST"
action="/login/verify"
class="space-y-6"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Authentication Code
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<div class="p-6">
<form
method="POST"
action="/login/verify"
class="space-y-4"
x-data="{ code: '', loading: false }"
@submit="loading = true">
<div>
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Authentication Code
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
id="code"
name="code"
x-model="code"
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 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"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
placeholder="Enter 6-digit code"
required/>
</div>
<input
type="text"
id="code"
name="code"
x-model="code"
class="form-input pl-10 w-full"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
placeholder="Enter 6-digit code"
required/>
</div>
</div>
<button
type="submit"
class="btn-primary w-full"
x-bind:disabled="code.length !== 6 || loading">
<span x-show="!loading">Verify</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
<button
type="submit"
class="w-full 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="code.length !== 6 || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-shield-alt mr-2"></i>
Verify
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verifying...
</span>
</button>
<div class="text-center">
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
Use a backup code instead
</a>
<div class="text-center">
<a href="/login/backup-code" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
Use a backup code instead
</a>
</div>
</form>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
<p>
Lost your device? You can use one of your backup codes instead of the 6-digit code. Backup codes were provided when you set up 2FA.
</p>
</div>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
Lost your device?
<br/>
You can use one of your backup codes instead of the 6-digit code.
</p>
</div>
</div>
</div>
<!-- Help Notice -->
<div class="mt-6 max-w-md mx-auto">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<h4 class="text-sm font-medium mb-1">Security Information</h4>
<p>Two-factor authentication adds an extra layer of security to your account. After entering your password, you'll need to provide a code from your authenticator app to verify your identity.</p>
</div>
</div>
</div>
+241
View File
@@ -0,0 +1,241 @@
package components
import (
"context"
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
)
type UserEditData struct {
User *db.User
Roles []db.Role
UserRoles []db.Role
IsNew bool
ErrorMessage string
}
templ UserEdit(ctx context.Context, data UserEditData) {
@LayoutWithContext("Edit User", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-edit w-6 h-6 mr-2 text-blue-500"></i>
if data.IsNew {
Add New User
} else {
Edit User
}
</h1>
</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>
<div id="error-message" class="hidden 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 error-title"></div>
<div class="error-details mt-1"></div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-6">
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
if data.IsNew {
hx-post="/admin/users"
} else {
hx-post={ "/admin/users/" + strconv.Itoa(int(data.User.ID)) }
hx-method="PUT"
}
hx-target="body">
<div>
<div class="mb-6">
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">User Information</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
if data.IsNew {
Create a new user account with appropriate permissions.
} else {
Update user information and permissions.
}
</p>
</div>
<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"
placeholder="user@example.com"
if !data.IsNew {
value={ data.User.Email }
}
/>
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
if data.IsNew {
Password
} else {
New Password (leave blank to keep current)
}
</label>
<input
type="password"
name="password"
id="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="••••••••"
if data.IsNew {
required="true"
}
/>
</div>
<div>
<label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
<input
type="password"
name="confirm_password"
id="confirm_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="••••••••"
/>
</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"
if !data.IsNew && data.User.GetIsAdmin() {
checked="true"
}
/>
</div>
<div class="ml-3 text-sm">
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Grant administrative privileges to this user</p>
</div>
</div>
</div>
if !data.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"
if data.User.GetAccountLocked() {
checked="true"
}
/>
</div>
<div class="ml-3 text-sm">
<label for="account_locked" class="font-medium text-gray-900 dark:text-white">Lock Account</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Prevent user from logging in</p>
</div>
</div>
<div class="mt-4">
<button
type="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-5 py-2.5 text-center inline-flex items-center mr-2"
hx-put={ "/admin/users/" + strconv.Itoa(int(data.User.ID)) + "/toggle-lock" }
hx-confirm="Are you sure you want to toggle this account's lock status?"
hx-target="body"
>
<i class="fas fa-lock w-4 h-4 mr-2"></i>
Toggle Lock Status
</button>
</div>
</div>
}
</div>
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="/admin/users" 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">
<i class="fas fa-arrow-left mr-2"></i>
Cancel
</a>
<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"
>
<i class="fas fa-save mr-2"></i>
if data.IsNew {
Create User
} else {
Update User
}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
}
}
// JavaScript to handle URL parameters for displaying messages
script handleUrlParams() {
document.addEventListener('DOMContentLoaded', function() {
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
const details = params.get('details');
const status = params.get('status');
if (error) {
const errorMsgEl = document.getElementById('error-message');
const errorTitleEl = errorMsgEl.querySelector('.error-title');
const errorDetailsEl = errorMsgEl.querySelector('.error-details');
errorTitleEl.textContent = error;
if (details) {
errorDetailsEl.textContent = details;
} else {
errorDetailsEl.textContent = '';
}
errorMsgEl.classList.remove('hidden');
// If notyf is available, use it
if (window.notyf) {
window.notyf.error(error);
}
}
if (status) {
const statusMsgEl = document.getElementById('status-message');
statusMsgEl.textContent = status;
statusMsgEl.classList.remove('hidden');
// If notyf is available, use it
if (window.notyf) {
window.notyf.success(status);
}
}
});
}
+379
View File
@@ -0,0 +1,379 @@
package components
import (
"context"
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
)
type UserManagementData struct {
Users []db.User
Roles []db.Role
ActiveTab string // "list", "create", or "edit"
EditUser *db.User
UserRoles []db.Role
ErrorMessage string
}
templ UserManagement(ctx context.Context, data UserManagementData) {
@LayoutWithContext("User Management", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
<!-- 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-users w-6 h-6 mr-2 text-blue-500"></i>
User Management
</h1>
<div class="flex items-center space-x-2">
<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-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.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">
if data.ActiveTab == "list" || data.ActiveTab == "" {
@userList(data.Users)
} else if data.ActiveTab == "create" {
@userForm(nil, true, data.Roles)
} else if data.ActiveTab == "edit" && data.EditUser != nil {
@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">
<i class="fas fa-shield-alt flex-shrink-0 inline w-4 h-4 me-3 mt-0.5"></i>
<span class="sr-only">Info</span>
<div>
User accounts provide secure access to the GoMFT application with role-based permissions
</div>
</div>
</div>
</div>
}
}
// User listing component
templ userList(users []db.User) {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
Email
</div>
</th>
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
Role
</div>
</th>
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-clock w-4 h-4 mr-1"></i>
Status
</div>
</th>
<th scope="col" class="px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
if len(users) == 0 {
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
No users found. Click the "Add User" button to create your first user.
</td>
</tr>
} else {
for _, user := range users {
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{ user.Email }
</td>
<td class="px-6 py-4">
if user.GetIsAdmin() {
<span class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-user-shield w-3 h-3 mr-1"></i> Admin
</span>
} else {
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300">
<i class="fas fa-user w-3 h-3 mr-1"></i> User
</span>
}
</td>
<td class="px-6 py-4">
if user.GetAccountLocked() {
<span class="bg-red-100 text-red-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-red-900 dark:text-red-300">
<i class="fas fa-lock w-3 h-3 mr-1"></i> Locked
</span>
} else {
<span class="bg-green-100 text-green-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1"></i> Active
</span>
}
</td>
<td class="px-6 py-4 text-right">
<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-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"
>
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
}
// User form component for creating/editing users
templ userForm(user *db.User, isNew bool, availableRoles []db.Role) {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-6">
<div class="mb-6">
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">
if isNew {
Create New User
} else {
Edit User: { user.Email }
}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
if isNew {
Create a new user account with appropriate permissions.
} else {
Update user information and permissions.
}
</p>
</div>
<form
class="space-y-6"
if isNew {
hx-post="/admin/users"
} else {
hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
}
hx-target="#user-management-content"
x-data="{
password: '',
confirmPassword: '',
isAdmin: false,
passwordsMatch() {
return this.password === this.confirmPassword || this.confirmPassword === '';
}
}"
>
<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"
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 {
Password
} else {
New Password (leave blank to keep current)
}
</label>
<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="••••••••"
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"
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="••••••••"
if isNew {
required="true"
}
/>
<p class="mt-1 text-sm text-red-600 dark:text-red-500" x-show="!passwordsMatch() && confirmPassword !== ''">
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"
if !isNew && user.GetIsAdmin() {
checked="true"
}
/>
</div>
<div class="ml-3 text-sm">
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Grant administrative privileges to this user</p>
</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"
if user.GetAccountLocked() {
checked="true"
}
/>
</div>
<div class="ml-3 text-sm">
<label for="account_locked" class="font-medium text-gray-900 dark:text-white">Lock Account</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Prevent user from logging in</p>
</div>
</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>
<div class="space-y-2">
for _, role := range availableRoles {
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id={ "role_" + strconv.Itoa(int(role.ID)) }
name="roles[]"
value={ strconv.Itoa(int(role.ID)) }
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"
}
/>
</div>
<div class="ml-3 text-sm">
<label for={ "role_" + strconv.Itoa(int(role.ID)) } class="font-medium text-gray-900 dark:text-white">{ role.Name }</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">{ role.Description }</p>
</div>
</div>
}
</div>
</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-push-url="true"
>
<i class="fas fa-arrow-left mr-2"></i>
Cancel
</button>
<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 !== ''"
>
<i class="fas fa-save mr-2"></i>
if isNew {
Create User
} else {
Update User
}
</button>
</div>
</form>
</div>
</div>
}
// Helper function to check if a user has a specific role
func hasRole(user *db.User, roleID uint) bool {
for _, role := range user.Roles {
if role.ID == roleID {
return true
}
}
return false
}
+147 -172
View File
@@ -1,9 +1,9 @@
package components
import (
"context"
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
"context"
)
type UsersData struct {
@@ -12,88 +12,98 @@ type UsersData struct {
templ Users(ctx context.Context, data UsersData) {
@LayoutWithContext("User Management", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
User Management
</h1>
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
<div class="mt-6">
if len(data.Users) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
<div class="mt-6">
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<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-users w-6 h-6 mr-2 text-blue-500"></i>
User Management
</h1>
<a href="/admin/users/new" 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">
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
Add User
</a>
</div>
<div class="mt-6">
if len(data.Users) == 0 {
<div class="flex p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900 dark:text-yellow-300" role="alert">
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
<span class="sr-only">Info</span>
<div>
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
</div>
} else {
<div class="card overflow-hidden">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead class="bg-secondary-50 dark:bg-secondary-800">
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-envelope mr-1"></i> Email
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
Email
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-user-tag mr-1"></i> Role
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
Role
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-calendar-plus mr-1"></i> Created
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-calendar-plus w-4 h-4 mr-1"></i>
Created
</div>
</th>
<th scope="col" class="relative px-6 py-3">
<th scope="col" class="px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody>
if len(data.Users) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
No users found
</td>
</tr>
} else {
for _, user := range data.Users {
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{ user.Email }
</td>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-6 py-4">
if user.GetIsAdmin() {
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
<i class="fas fa-user-shield mr-1"></i> Admin
<span class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-user-shield w-3 h-3 mr-1"></i> Admin
</span>
} else {
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
<i class="fas fa-user mr-1"></i> User
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300">
<i class="fas fa-user w-3 h-3 mr-1"></i> User
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
<td class="px-6 py-4">
{ user.CreatedAt.Format("Jan 02, 2006") }
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td class="px-6 py-4 text-right">
<a
href={ templ.SafeURL("/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit") }
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"
>
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
</a>
<button
class="btn-danger btn-sm"
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="body"
>
<i class="fas fa-trash-alt mr-1"></i> Delete
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
</button>
</td>
</tr>
@@ -102,15 +112,18 @@ templ Users(ctx context.Context, data UsersData) {
</tbody>
</table>
</div>
}
</div>
<!-- Help Notice -->
<div class="mt-8 text-center">
<p class="text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
</div>
}
</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">
<i class="fas fa-shield-alt flex-shrink-0 inline w-4 h-4 me-3 mt-0.5"></i>
<span class="sr-only">Info</span>
<div>
User accounts provide secure access to the GoMFT application with role-based permissions
</p>
</div>
</div>
</div>
</div>
@@ -124,163 +137,125 @@ type UserFormData struct {
templ UserForm(ctx context.Context, data UserFormData) {
@LayoutWithContext("Add User", ctx) {
<div class="py-6">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="p-4 md:p-6 2xl:p-10">
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
Add New User
</h1>
</div>
<div class="card overflow-hidden">
if data.ErrorMessage != "" {
<div class="mb-6 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.ErrorMessage }</span>
</div>
</div>
}
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-6">
if data.ErrorMessage != "" {
<div class="mb-6">
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ data.ErrorMessage }</span>
</div>
</div>
</div>
}
<form
class="space-y-6"
hx-post="/admin/users"
hx-target="body"
action="/admin/users/new"
method="POST"
x-data="{
email: '',
password: '',
confirmPassword: '',
isAdmin: false,
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
passwordsMatch() {
return this.password === this.confirmPassword || this.confirmPassword === '';
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
>
<div>
<div class="mb-6">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Create a new user account with appropriate permissions.</p>
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">User Information</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create a new user account with appropriate permissions.</p>
</div>
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="email"
name="email"
id="email"
x-model="email"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="user@example.com" />
</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"
placeholder="user@example.com"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="password"
id="password"
x-model="password"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••" />
</div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<input
type="password"
name="password"
id="password"
required
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="••••••••"
/>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
type="password"
name="confirm_password"
id="confirm_password"
x-model="confirmPassword"
required
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••" />
<p class="mt-1 text-sm text-red-600 dark:text-red-400" x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</p>
</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"
required
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="••••••••"
/>
<p class="mt-1 text-sm text-red-600 dark:text-red-500" x-show="!passwordsMatch() && confirmPassword !== ''">
Passwords do not match
</p>
</div>
<div class="pt-2">
<div class="relative flex items-start">
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="is_admin"
name="is_admin"
type="checkbox"
x-model="isAdmin"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
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"
/>
</div>
<div class="ml-3 text-sm">
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Grant administrative privileges to this user</p>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
<div class="flex justify-end space-x-3">
<a href="/admin/users" class="btn-secondary">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
<button
type="submit"
class="btn-primary"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-user-plus mr-2"></i>
Create User
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="/admin/users" 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">
<i class="fas fa-arrow-left mr-2"></i>
Cancel
</a>
<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 !== ''"
>
<i class="fas fa-save mr-2"></i>
Create User
</button>
</div>
</form>
</div>
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>User accounts provide secure access to the GoMFT application</span>
</div>
</div>
</div>
</div>
</div>
}
}
}
+4 -4
View File
@@ -13,7 +13,7 @@ require (
github.com/pquerna/otp v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.35.0
golang.org/x/crypto v0.36.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/gorm v1.25.12
)
@@ -51,9 +51,9 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
+8 -8
View File
@@ -107,15 +107,15 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+46
View File
@@ -0,0 +1,46 @@
package db
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
// AuditLog represents an audit trail entry in the system
type AuditLog struct {
gorm.Model
Action string `gorm:"size:50;not null;index"`
EntityType string `gorm:"size:50;not null;index"`
EntityID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
Details AuditLogDetails `gorm:"type:json"`
Timestamp time.Time `gorm:"not null;index;default:CURRENT_TIMESTAMP"`
}
// AuditLogDetails is a custom type for storing audit log details as JSON
type AuditLogDetails map[string]interface{}
// Scan implements the sql.Scanner interface
func (d *AuditLogDetails) Scan(value interface{}) error {
if value == nil {
*d = make(AuditLogDetails)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, d)
}
// Value implements the driver.Valuer interface
func (d AuditLogDetails) Value() (driver.Value, error) {
if d == nil {
return json.Marshal(make(map[string]interface{}))
}
return json.Marshal(d)
}
+174 -1
View File
@@ -1,6 +1,7 @@
package db
import (
"errors"
"fmt"
"log"
"os"
@@ -29,6 +30,7 @@ type User struct {
TwoFactorSecret string `gorm:"type:varchar(32)"`
TwoFactorEnabled bool `gorm:"default:false"`
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
Roles []Role `gorm:"many2many:user_roles"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -219,6 +221,8 @@ type JobHistory struct {
BytesTransferred int64
FilesTransferred int
ErrorMessage string
CreatedAt time.Time
UpdatedAt time.Time
}
// FileMetadata stores information about processed files
@@ -259,7 +263,7 @@ func Initialize(dbPath string) (*DB, error) {
}
// Initialize and run migrations
m := migrations.InitMigrations(db)
m := migrations.GetMigrations(db)
if err := m.Migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %v", err)
}
@@ -267,6 +271,18 @@ func Initialize(dbPath string) (*DB, error) {
return &DB{DB: db}, nil
}
// ReopenWithoutMigrations reopens the database connection without running migrations
// This should be used when temporarily closing and reopening the database
func ReopenWithoutMigrations(dbPath string) (*DB, error) {
// Open database connection with modernc.org/sqlite driver
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %v", err)
}
return &DB{DB: db}, nil
}
func (db *DB) Close() error {
sqlDB, err := db.DB.DB()
if err != nil {
@@ -1508,3 +1524,160 @@ func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
tc.UseBuiltinAuthDest = &value
}
// HasRole checks if the user has a specific role
func (u *User) HasRole(roleName string) bool {
for _, role := range u.Roles {
if role.Name == roleName {
return true
}
}
return false
}
// HasPermission checks if the user has a specific permission through any of their roles
func (u *User) HasPermission(permission string) bool {
for _, role := range u.Roles {
if role.HasPermission(permission) {
return true
}
}
return false
}
// GetRoles returns all roles assigned to the user
func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) {
var roles []Role
err := tx.Model(u).Association("Roles").Find(&roles)
return roles, err
}
// AssignRole assigns a role to the user
func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error {
var role Role
if err := tx.First(&role, roleID).Error; err != nil {
return err
}
return role.AssignToUser(tx, u.ID, assignedByID)
}
// UnassignRole removes a role from the user
func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error {
var role Role
if err := tx.First(&role, roleID).Error; err != nil {
return err
}
return role.UnassignFromUser(tx, u.ID, unassignedByID)
}
// Role operations
func (db *DB) CreateRole(role *Role) error {
return db.Create(role).Error
}
func (db *DB) GetRole(id uint) (*Role, error) {
var role Role
err := db.First(&role, id).Error
if err != nil {
return nil, err
}
return &role, nil
}
func (db *DB) GetRoleByName(name string) (*Role, error) {
var role Role
err := db.Where("name = ?", name).First(&role).Error
if err != nil {
return nil, err
}
return &role, nil
}
func (db *DB) UpdateRole(role *Role) error {
return db.Save(role).Error
}
func (db *DB) DeleteRole(id uint) error {
var role Role
if err := db.First(&role, id).Error; err != nil {
return err
}
if role.IsSystemRole() {
return errors.New("cannot delete system role")
}
// Start transaction
tx := db.Begin()
if err := tx.Error; err != nil {
return err
}
// Delete role assignments
if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil {
tx.Rollback()
return err
}
// Delete the role
if err := tx.Delete(&role).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
func (db *DB) ListRoles() ([]Role, error) {
var roles []Role
err := db.Find(&roles).Error
return roles, err
}
func (db *DB) GetUserRoles(userID uint) ([]Role, error) {
var user User
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
return nil, err
}
return user.Roles, nil
}
// AssignRoleToUser assigns a role to a user
func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error {
var role Role
if err := db.First(&role, roleID).Error; err != nil {
return err
}
tx := db.Begin()
if err := tx.Error; err != nil {
return err
}
if err := role.AssignToUser(tx, userID, assignedByID); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// UnassignRoleFromUser removes a role from a user
func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
var role Role
if err := db.First(&role, roleID).Error; err != nil {
return err
}
tx := db.Begin()
if err := tx.Error; err != nil {
return err
}
if err := role.UnassignFromUser(tx, userID, unassignedByID); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
@@ -2,9 +2,6 @@ package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
@@ -14,60 +11,6 @@ func InitialSchema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "001_initial_schema",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Disable foreign key constraints while creating tables
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign key constraints: %v", err)
@@ -1,6 +1,11 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
@@ -10,6 +15,60 @@ func UpdateGDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "002_update_gdrive_type",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
return err
@@ -0,0 +1,133 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddAuditLogs creates a migration for adding audit logs and user roles tables
func AddAuditLogs() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "004_add_audit_logs",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Create roles table first
if err := tx.Exec(`
CREATE TABLE roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
permissions TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
)
`).Error; err != nil {
return err
}
// Create audit_logs table
if err := tx.Exec(`
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
details TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT idx_audit_logs_action CHECK (action IS NOT NULL),
CONSTRAINT idx_audit_logs_entity_type CHECK (entity_type IS NOT NULL),
CONSTRAINT idx_audit_logs_entity_id CHECK (entity_id IS NOT NULL),
CONSTRAINT idx_audit_logs_user_id CHECK (user_id IS NOT NULL),
CONSTRAINT idx_audit_logs_timestamp CHECK (timestamp IS NOT NULL)
)
`).Error; err != nil {
return err
}
// Create user_roles table for many-to-many relationship
return tx.Exec(`
CREATE TABLE user_roles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
)
`).Error
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Exec("DROP TABLE IF EXISTS user_roles").Error; err != nil {
return err
}
if err := tx.Exec("DROP TABLE IF EXISTS audit_logs").Error; err != nil {
return err
}
return tx.Exec("DROP TABLE IF EXISTS roles").Error
},
}
}
@@ -0,0 +1,227 @@
package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddDefaultRoles creates a migration for adding default system roles
func AddDefaultRoles() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "005_add_default_roles",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Define default roles with their permissions
defaultRoles := []struct {
Name string
Description string
Permissions []string
}{
{
Name: "admin",
Description: "Full system administrator access",
Permissions: []string{
"users.view", "users.create", "users.edit", "users.delete",
"roles.view", "roles.create", "roles.edit", "roles.delete",
"configs.view", "configs.create", "configs.edit", "configs.delete",
"jobs.view", "jobs.create", "jobs.edit", "jobs.delete", "jobs.run",
"audit.view", "audit.export",
"system.settings", "system.backup", "system.restore",
},
},
{
Name: "system",
Description: "System-level access for automated processes",
Permissions: []string{
"jobs.view", "jobs.create", "jobs.edit", "jobs.delete", "jobs.run",
"configs.view", "configs.create", "configs.edit",
"audit.view",
},
},
{
Name: "user",
Description: "Standard user access",
Permissions: []string{
"jobs.view", "jobs.run",
"configs.view",
"audit.view",
},
},
}
// Create each role
for _, role := range defaultRoles {
// Convert permissions to JSON string
permsJSON, err := json.Marshal(role.Permissions)
if err != nil {
return err
}
// Check if role already exists
var count int64
if err := tx.Table("roles").Where("name = ?", role.Name).Count(&count).Error; err != nil {
return err
}
// Skip if role already exists
if count > 0 {
continue
}
// Create the role
now := time.Now()
if err := tx.Exec(`
INSERT INTO roles (name, description, permissions, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, role.Name, role.Description, string(permsJSON), now, now).Error; err != nil {
return err
}
// Create audit log entry with system user ID (1)
details, err := json.Marshal(map[string]interface{}{
"role_name": role.Name,
"permissions": role.Permissions,
"description": role.Description,
"system_created": true,
})
if err != nil {
return err
}
if err := tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
SELECT 'create_role', 'role', id, 1, ?, ?, ?, ?
FROM roles WHERE name = ?
`, string(details), now, now, now, role.Name).Error; err != nil {
return err
}
}
// Assign admin role to admin user (ID 1)
now := time.Now()
// Check if admin user exists
var adminUserCount int64
if err := tx.Table("users").Where("id = ?", 1).Count(&adminUserCount).Error; err != nil {
return err
}
if adminUserCount == 0 {
return nil // Skip if admin user doesn't exist yet
}
// Get admin role ID
var adminRoleID uint
if err := tx.Table("roles").Select("id").Where("name = ?", "admin").Row().Scan(&adminRoleID); err != nil {
return err
}
// Check if role is already assigned
var assignmentCount int64
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", 1, adminRoleID).Count(&assignmentCount).Error; err != nil {
return err
}
if assignmentCount > 0 {
return nil // Skip if already assigned
}
// Assign admin role to admin user
if err := tx.Exec(`
INSERT INTO user_roles (user_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, 1, adminRoleID, now, now).Error; err != nil {
return err
}
// Create audit log for role assignment
details, err := json.Marshal(map[string]interface{}{
"role_name": "admin",
"user_id": 1,
"system_created": true,
})
if err != nil {
return err
}
return tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('assign_role', 'role', ?, 1, ?, ?, ?, ?)
`, adminRoleID, string(details), now, now, now).Error
},
Rollback: func(tx *gorm.DB) error {
// Don't delete the roles on rollback as they might be in use
// Instead, log that we're skipping role deletion
now := time.Now()
details, err := json.Marshal(map[string]interface{}{
"message": "Skipped deletion of default roles for safety",
})
if err != nil {
return err
}
return tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('migration_rollback', 'roles', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error
},
}
}
@@ -0,0 +1,71 @@
package migrations
import (
"encoding/json"
"fmt"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddTimestampsToJobHistories creates a migration for adding timestamp columns to job_histories table
func AddTimestampsToJobHistories() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "006_add_timestamps_to_job_histories",
Migrate: func(tx *gorm.DB) error {
// Add created_at column
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN created_at DATETIME").Error; err != nil {
return fmt.Errorf("failed to add created_at column: %v", err)
}
// Add updated_at column
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN updated_at DATETIME").Error; err != nil {
return fmt.Errorf("failed to add updated_at column: %v", err)
}
// Set default values for existing records
now := time.Now()
if err := tx.Exec("UPDATE job_histories SET created_at = ?, updated_at = ? WHERE created_at IS NULL", now, now).Error; err != nil {
return fmt.Errorf("failed to update existing records: %v", err)
}
// Create audit log entry for the migration
details, err := json.Marshal(map[string]interface{}{
"columns_added": []string{"created_at", "updated_at"},
"table": "job_histories",
})
if err != nil {
return err
}
return tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('schema_update', 'job_histories', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error
},
Rollback: func(tx *gorm.DB) error {
// In SQLite, we can't drop columns directly, so we'd need to:
// 1. Create a new table without the columns
// 2. Copy data from the old table
// 3. Drop the old table
// 4. Rename the new table
//
// However, keeping the timestamp columns is generally harmless,
// so we'll just log a message instead of performing a risky rollback
now := time.Now()
details, err := json.Marshal(map[string]interface{}{
"message": "Skipped removal of timestamp columns from job_histories due to SQLite limitations",
})
if err != nil {
return err
}
return tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('migration_rollback', 'job_histories', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error
},
}
}
@@ -0,0 +1,78 @@
package migrations
import (
"encoding/json"
"fmt"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddNotificationServices creates a migration for adding the notification services table
func AddNotificationServices() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "007_add_notification_services",
Migrate: func(tx *gorm.DB) error {
// Create notification_services table
type NotificationService struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
Type string `gorm:"not null"` // email, slack, webhook
IsEnabled bool `gorm:"default:true"`
ConfigJSON string `gorm:"column:config"`
Description string
EventTriggers string `gorm:"column:event_triggers;default:'[]'"`
PayloadTemplate string `gorm:"column:payload_template"`
SecretKey string `gorm:"column:secret_key"`
RetryPolicy string `gorm:"column:retry_policy;default:'simple'"`
LastUsed time.Time `gorm:"column:last_used"`
SuccessCount int `gorm:"column:success_count;default:0"`
FailureCount int `gorm:"column:failure_count;default:0"`
CreatedBy uint
CreatedAt time.Time
UpdatedAt time.Time
}
if err := tx.AutoMigrate(&NotificationService{}); err != nil {
return fmt.Errorf("failed to create notification_services table: %v", err)
}
// Create audit log entry
now := time.Now()
details, err := json.Marshal(map[string]interface{}{
"table_name": "notification_services",
"system_created": true,
})
if err != nil {
return err
}
return tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('create_table', 'schema', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error
},
Rollback: func(tx *gorm.DB) error {
// Log rollback operation
now := time.Time{}
details, err := json.Marshal(map[string]interface{}{
"table_name": "notification_services",
"message": "Dropping notification_services table",
})
if err != nil {
return err
}
if err := tx.Exec(`
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
VALUES ('drop_table', 'schema', 0, 1, ?, ?, ?, ?)
`, string(details), now, now, now).Error; err != nil {
return err
}
// Drop the table
return tx.Migrator().DropTable("notification_services")
},
}
}
+14 -7
View File
@@ -5,13 +5,20 @@ import (
"gorm.io/gorm"
)
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
InitialSchema(),
UpdateGDriveType(),
Add2FA(),
}
var migrations []*gormigrate.Migration
// GetMigrations returns all migrations
func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
// Add all migrations in order
migrations = append(migrations,
InitialSchema(), // 001
UpdateGDriveType(), // 002
Add2FA(), // 003
AddAuditLogs(), // 004
AddDefaultRoles(), // 005
AddTimestampsToJobHistories(), // 006
AddNotificationServices(), // 007
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
}
+299
View File
@@ -0,0 +1,299 @@
package db
import (
"database/sql/driver"
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
)
// Role represents a role in the system with associated permissions
type Role struct {
gorm.Model
Name string `gorm:"size:255;not null;unique"`
Description string `gorm:"type:text"`
Permissions Permissions `gorm:"type:text"`
Users []User `gorm:"many2many:user_roles;"`
}
// AssignToUser assigns this role to a user
func (r *Role) AssignToUser(tx *gorm.DB, userID uint, assignedByID uint) error {
// Check if the role is already assigned
var count int64
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("role is already assigned to user")
}
// Create the assignment
if err := tx.Exec("INSERT INTO user_roles (user_id, role_id, created_at, updated_at) VALUES (?, ?, ?, ?)",
userID, r.ID, time.Now(), time.Now()).Error; err != nil {
return err
}
// Create audit log
return tx.Create(&AuditLog{
Action: "assign_role",
EntityType: "role",
EntityID: r.ID,
UserID: assignedByID,
Details: map[string]interface{}{
"role_name": r.Name,
"user_id": userID,
},
Timestamp: time.Now(),
}).Error
}
// UnassignFromUser removes this role from a user
func (r *Role) UnassignFromUser(tx *gorm.DB, userID uint, unassignedByID uint) error {
// Check if the role is assigned
var count int64
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("role is not assigned to user")
}
// Remove the assignment
if err := tx.Exec("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userID, r.ID).Error; err != nil {
return err
}
// Create audit log
return tx.Create(&AuditLog{
Action: "unassign_role",
EntityType: "role",
EntityID: r.ID,
UserID: unassignedByID,
Details: map[string]interface{}{
"role_name": r.Name,
"user_id": userID,
},
Timestamp: time.Now(),
}).Error
}
// Validate performs validation on the role
func (r *Role) Validate() error {
// Name validation
if strings.TrimSpace(r.Name) == "" {
return errors.New("role name cannot be empty")
}
if len(r.Name) > 255 {
return errors.New("role name cannot exceed 255 characters")
}
if len(r.Description) > 1000 {
return errors.New("description cannot exceed 1000 characters")
}
// Permission validation
if r.Permissions == nil {
return errors.New("permissions cannot be nil")
}
// Validate each permission
validPerms := map[string]bool{
// User management
"users.view": true,
"users.create": true,
"users.edit": true,
"users.delete": true,
// Role management
"roles.view": true,
"roles.create": true,
"roles.edit": true,
"roles.delete": true,
// Config management
"configs.view": true,
"configs.create": true,
"configs.edit": true,
"configs.delete": true,
// Job management
"jobs.view": true,
"jobs.create": true,
"jobs.edit": true,
"jobs.delete": true,
"jobs.run": true,
// Audit logs
"audit.view": true,
"audit.export": true,
// System settings
"system.settings": true,
"system.backup": true,
"system.restore": true,
}
for _, perm := range r.Permissions {
if !validPerms[perm] {
return errors.New("invalid permission: " + perm)
}
}
return nil
}
// BeforeSave is a GORM hook that runs before saving the role
func (r *Role) BeforeSave(tx *gorm.DB) error {
return r.Validate()
}
// BeforeDelete is a GORM hook that runs before deleting the role
func (r *Role) BeforeDelete(tx *gorm.DB) error {
if r.IsSystemRole() {
return errors.New("cannot delete system role")
}
// Create audit log for deletion
return tx.Create(&AuditLog{
Action: "delete_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// AfterCreate is a GORM hook that runs after creating the role
func (r *Role) AfterCreate(tx *gorm.DB) error {
// Create audit log for creation
return tx.Create(&AuditLog{
Action: "create_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// AfterUpdate is a GORM hook that runs after updating the role
func (r *Role) AfterUpdate(tx *gorm.DB) error {
// Create audit log for update
return tx.Create(&AuditLog{
Action: "update_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// Permissions is a custom type for storing permissions as a JSON array
type Permissions []string
// Scan implements the sql.Scanner interface
func (p *Permissions) Scan(value interface{}) error {
if value == nil {
*p = make(Permissions, 0)
return nil
}
var str string
switch v := value.(type) {
case []byte:
str = string(v)
case string:
str = v
default:
return errors.New("failed to scan Permissions: invalid type")
}
return json.Unmarshal([]byte(str), p)
}
// Value implements the driver.Valuer interface
func (p Permissions) Value() (driver.Value, error) {
if p == nil {
p = make(Permissions, 0)
}
bytes, err := json.Marshal(p)
if err != nil {
return nil, err
}
return string(bytes), nil
}
// HasPermission checks if the role has a specific permission
func (r *Role) HasPermission(permission string) bool {
for _, p := range r.Permissions {
if p == permission {
return true
}
}
return false
}
// AddPermission adds a permission to the role if it doesn't already exist
func (r *Role) AddPermission(permission string) {
if !r.HasPermission(permission) {
r.Permissions = append(r.Permissions, permission)
}
}
// RemovePermission removes a permission from the role
func (r *Role) RemovePermission(permission string) {
if r.Permissions == nil {
return
}
for i, p := range r.Permissions {
if p == permission {
r.Permissions = append(r.Permissions[:i], r.Permissions[i+1:]...)
return
}
}
}
// SetPermissions sets the role's permissions
func (r *Role) SetPermissions(permissions []string) {
r.Permissions = permissions
}
// GetPermissions returns the role's permissions
func (r *Role) GetPermissions() []string {
if r.Permissions == nil {
return make([]string, 0)
}
return r.Permissions
}
// IsSystemRole checks if this is a system-defined role that shouldn't be modified
func (r *Role) IsSystemRole() bool {
systemRoles := []string{"admin", "system", "superuser"}
for _, role := range systemRoles {
if strings.ToLower(r.Name) == role {
return true
}
}
return false
}
// AuditLog creates an audit log entry for role changes
func (r *Role) AuditLog(tx *gorm.DB, action string, userID uint) error {
return tx.Create(&AuditLog{
Action: action,
EntityType: "role",
EntityID: r.ID,
UserID: userID,
Details: map[string]interface{}{"name": r.Name, "permissions": r.Permissions},
}).Error
}
+573
View File
@@ -0,0 +1,573 @@
package handlers
import (
"encoding/csv"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// HandleAdminDashboard renders the admin dashboard page
func (h *Handlers) HandleAdminDashboard(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
_ = components.AdminLayout(ctx).Render(c.Request.Context(), c.Writer)
}
// HandleRoles renders the role management page
func (h *Handlers) HandleRoles(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
// Fetch roles from the database
var dbRoles []db.Role
if err := h.DB.Find(&dbRoles).Error; err != nil {
data := components.RolesData{
Error: "Failed to fetch roles: " + err.Error(),
}
_ = components.AdminRoles(ctx, data).Render(ctx, c.Writer)
return
}
// Convert db.Role to components.Role
roles := make([]components.Role, len(dbRoles))
for i, role := range dbRoles {
roles[i] = components.Role{
ID: role.ID,
Name: role.Name,
Description: role.Description,
Permissions: role.GetPermissions(),
}
}
data := components.RolesData{
Roles: roles,
}
_ = components.AdminRoles(ctx, data).Render(ctx, c.Writer)
}
// HandleNewRole renders the new role creation page
func (h *Handlers) HandleNewRole(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
data := components.RoleFormData{
Role: &components.Role{},
IsNew: true,
AllPermissions: GetAllPermissions(),
}
_ = components.AdminRoleForm(ctx, data).Render(ctx, c.Writer)
}
// HandleCreateRole processes role creation
func (h *Handlers) HandleCreateRole(c *gin.Context) {
name := c.PostForm("name")
description := c.PostForm("description")
permissions := c.PostFormArray("permissions[]")
// Create role
role := &db.Role{
Name: name,
Description: description,
}
role.SetPermissions(permissions)
// Validate role
if err := role.Validate(); err != nil {
ctx := components.CreateTemplateContext(c)
data := components.RoleFormData{
Role: &components.Role{Name: name, Description: description, Permissions: permissions},
IsNew: true,
ErrorMessage: err.Error(),
AllPermissions: GetAllPermissions(),
}
_ = components.AdminRoleForm(ctx, data).Render(ctx, c.Writer)
return
}
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
handleRoleError(c, role, true, "Failed to begin transaction: "+err.Error())
return
}
// Create role in database
if err := tx.Create(role).Error; err != nil {
tx.Rollback()
handleRoleError(c, role, true, "Failed to create role: "+err.Error())
return
}
// Create audit log
userID := getUserID(c) // Implement this helper to get current user ID
if err := role.AuditLog(tx, "create", userID); err != nil {
tx.Rollback()
handleRoleError(c, role, true, "Failed to create audit log: "+err.Error())
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
handleRoleError(c, role, true, "Failed to commit transaction: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/admin/roles")
}
// HandleEditRole renders the role edit page
func (h *Handlers) HandleEditRole(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
id := c.Param("id")
var role db.Role
if err := h.DB.First(&role, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/roles")
return
}
data := components.RoleFormData{
Role: &components.Role{
ID: role.ID,
Name: role.Name,
Description: role.Description,
Permissions: role.GetPermissions(),
},
IsNew: false,
AllPermissions: GetAllPermissions(),
}
_ = components.AdminRoleForm(ctx, data).Render(ctx, c.Writer)
}
// HandleUpdateRole processes role updates
func (h *Handlers) HandleUpdateRole(c *gin.Context) {
id := c.Param("id")
name := c.PostForm("name")
description := c.PostForm("description")
permissions := c.PostFormArray("permissions[]")
// Get existing role
var role db.Role
if err := h.DB.First(&role, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/roles")
return
}
// Check if this is a system role
if role.IsSystemRole() {
handleRoleError(c, &role, false, "Cannot modify system role")
return
}
// Update role fields
role.Name = name
role.Description = description
role.SetPermissions(permissions)
// Validate role
if err := role.Validate(); err != nil {
handleRoleError(c, &role, false, err.Error())
return
}
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
handleRoleError(c, &role, false, "Failed to begin transaction: "+err.Error())
return
}
// Update role in database
if err := tx.Save(&role).Error; err != nil {
tx.Rollback()
handleRoleError(c, &role, false, "Failed to update role: "+err.Error())
return
}
// Create audit log
userID := getUserID(c)
if err := role.AuditLog(tx, "update", userID); err != nil {
tx.Rollback()
handleRoleError(c, &role, false, "Failed to create audit log: "+err.Error())
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
handleRoleError(c, &role, false, "Failed to commit transaction: "+err.Error())
return
}
c.Redirect(http.StatusFound, "/admin/roles")
}
// HandleDeleteRole processes role deletion
func (h *Handlers) HandleDeleteRole(c *gin.Context) {
id := c.Param("id")
// Get existing role
var role db.Role
if err := h.DB.First(&role, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Role not found"})
return
}
// Check if this is a system role
if role.IsSystemRole() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete system role"})
return
}
// Start transaction
tx := h.DB.Begin()
if err := tx.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
// Create audit log before deletion
userID := getUserID(c)
if err := role.AuditLog(tx, "delete", userID); err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
return
}
// Delete role
if err := tx.Delete(&role).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete role"})
return
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
c.Status(http.StatusOK)
}
// GetAllPermissions returns a list of all available permissions
func GetAllPermissions() []string {
return []string{
"users.view",
"users.create",
"users.edit",
"users.delete",
"roles.view",
"roles.create",
"roles.edit",
"roles.delete",
"configs.view",
"configs.create",
"configs.edit",
"configs.delete",
"jobs.view",
"jobs.create",
"jobs.edit",
"jobs.delete",
"jobs.run",
"audit.view",
"audit.export",
"system.settings",
"system.backup",
"system.restore",
}
}
// Helper function to parse uint from string
func parseUint(s string) uint {
id, _ := strconv.ParseUint(s, 10, 32)
return uint(id)
}
// HandleAuditLogs renders the audit logs page
func (h *Handlers) HandleAuditLogs(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
// Get filter parameters from query
action := c.Query("action")
entity := c.Query("entity")
user := c.Query("user")
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
// Get page parameter
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
// Items per page
const perPage = 20
// Initialize base query
query := h.DB.Model(&db.AuditLog{}).Order("timestamp DESC")
// Apply filters
if action != "" {
query = query.Where("action = ?", action)
}
if entity != "" {
query = query.Where("entity_type = ?", entity)
}
if user != "" {
// Join with users table to search by username
query = query.Joins("LEFT JOIN users ON audit_logs.user_id = users.id").
Where("users.email LIKE ?", "%"+user+"%")
}
if dateFrom != "" {
fromDate, err := time.Parse("2006-01-02", dateFrom)
if err == nil {
query = query.Where("timestamp >= ?", fromDate)
}
}
if dateTo != "" {
toDate, err := time.Parse("2006-01-02", dateTo)
if err == nil {
// Add one day to include the end date
toDate = toDate.Add(24 * time.Hour)
query = query.Where("timestamp < ?", toDate)
}
}
// Count total records
var totalRecords int64
if err := query.Count(&totalRecords).Error; err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database error", err.Error(), err)
return
}
// Calculate pagination
totalPages := int(math.Ceil(float64(totalRecords) / float64(perPage)))
offset := (page - 1) * perPage
// Get logs for current page
var dbLogs []db.AuditLog
if err := query.Limit(perPage).Offset(offset).Find(&dbLogs).Error; err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database error", err.Error(), err)
return
}
// Get usernames for user IDs
userIDs := make([]uint, len(dbLogs))
for i, log := range dbLogs {
userIDs[i] = log.UserID
}
var users []db.User
if err := h.DB.Where("id IN ?", userIDs).Find(&users).Error; err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database error", err.Error(), err)
return
}
// Create map of user IDs to usernames
userMap := make(map[uint]string)
for _, u := range users {
userMap[u.ID] = u.Email // Use email as username
}
// Convert to display format
logs := make([]components.AuditLogEntry, len(dbLogs))
for i, log := range dbLogs {
// Serialize details to JSON for display
detailsJSON, _ := json.Marshal(log.Details)
logs[i] = components.AuditLogEntry{
ID: log.ID,
Action: log.Action,
EntityType: log.EntityType,
EntityID: log.EntityID,
UserID: log.UserID,
Username: userMap[log.UserID],
Timestamp: log.Timestamp,
Details: log.Details,
DetailsSummary: string(detailsJSON),
}
}
// Prepare data for template
data := components.AuditLogsData{
Logs: logs,
TotalPages: totalPages,
CurrentPage: page,
TotalRecords: int(totalRecords),
FilterAction: action,
FilterEntity: entity,
FilterUser: user,
FilterDateFrom: dateFrom,
FilterDateTo: dateTo,
}
_ = components.AdminAuditLogs(ctx, data).Render(ctx, c.Writer)
}
// HandleExportAuditLogs exports audit logs as CSV
func (h *Handlers) HandleExportAuditLogs(c *gin.Context) {
// Get filter parameters from query
action := c.Query("action")
entity := c.Query("entity")
user := c.Query("user")
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
// Initialize base query
query := h.DB.Model(&db.AuditLog{}).Order("timestamp DESC")
// Apply filters
if action != "" {
query = query.Where("action = ?", action)
}
if entity != "" {
query = query.Where("entity_type = ?", entity)
}
if user != "" {
// Join with users table to search by username
query = query.Joins("LEFT JOIN users ON audit_logs.user_id = users.id").
Where("users.email LIKE ?", "%"+user+"%")
}
if dateFrom != "" {
fromDate, err := time.Parse("2006-01-02", dateFrom)
if err == nil {
query = query.Where("timestamp >= ?", fromDate)
}
}
if dateTo != "" {
toDate, err := time.Parse("2006-01-02", dateTo)
if err == nil {
// Add one day to include the end date
toDate = toDate.Add(24 * time.Hour)
query = query.Where("timestamp < ?", toDate)
}
}
// Get all logs matching the filters
var dbLogs []db.AuditLog
if err := query.Find(&dbLogs).Error; err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database error", err.Error(), err)
return
}
// Get usernames for user IDs
userIDs := make([]uint, len(dbLogs))
for i, log := range dbLogs {
userIDs[i] = log.UserID
}
var users []db.User
if err := h.DB.Where("id IN ?", userIDs).Find(&users).Error; err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database error", err.Error(), err)
return
}
// Create map of user IDs to usernames
userMap := make(map[uint]string)
for _, u := range users {
userMap[u.ID] = u.Email // Use email as username
}
// Set headers for CSV download
filename := fmt.Sprintf("audit_logs_%s.csv", time.Now().Format("2006-01-02"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Type", "text/csv")
// Create CSV writer
writer := csv.NewWriter(c.Writer)
// Write header row
headers := []string{"ID", "Timestamp", "Action", "Entity Type", "Entity ID", "User", "Details"}
if err := writer.Write(headers); err != nil {
h.HandleError(c, http.StatusInternalServerError, "Failed to write CSV", err.Error(), err)
return
}
// Write data rows
for _, log := range dbLogs {
// Serialize details to JSON for CSV
detailsJSON, _ := json.Marshal(log.Details)
row := []string{
fmt.Sprintf("%d", log.ID),
log.Timestamp.Format("2006-01-02 15:04:05"),
log.Action,
log.EntityType,
fmt.Sprintf("%d", log.EntityID),
userMap[log.UserID],
string(detailsJSON),
}
if err := writer.Write(row); err != nil {
// Don't stop on error, just continue
fmt.Printf("Failed to write CSV row: %v\n", err)
continue
}
}
// Flush writer
writer.Flush()
if err := writer.Error(); err != nil {
fmt.Printf("Error flushing CSV writer: %v\n", err)
}
}
// HandleSystemSettings renders the system settings page
func (h *Handlers) HandleSystemSettings(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
// In a real implementation, you'd fetch system settings from the database
_ = components.AdminLayout(ctx).Render(c.Request.Context(), c.Writer)
}
// HandleUpdateSystemSettings processes system settings updates
func (h *Handlers) HandleUpdateSystemSettings(c *gin.Context) {
// Implementation for updating system settings
// ...
c.Redirect(http.StatusFound, "/admin/settings")
}
// Helper function to handle role errors
func handleRoleError(c *gin.Context, role *db.Role, isNew bool, errorMessage string) {
ctx := components.CreateTemplateContext(c)
data := components.RoleFormData{
Role: &components.Role{
ID: role.ID,
Name: role.Name,
Description: role.Description,
Permissions: role.GetPermissions(),
},
IsNew: isNew,
ErrorMessage: errorMessage,
AllPermissions: GetAllPermissions(),
}
_ = components.AdminRoleForm(ctx, data).Render(ctx, c.Writer)
}
// Helper function to get current user ID from context
func getUserID(c *gin.Context) uint {
user, exists := c.Get("user")
if !exists {
return 0
}
if u, ok := user.(*db.User); ok {
return u.ID
}
return 0
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+95 -6
View File
@@ -58,7 +58,13 @@ func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
}
// Safely extract claims with type assertions and defaults
userID, _ := claims["user_id"].(float64)
userID, ok := claims["user_id"].(float64)
if !ok || userID <= 0 {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
email, _ := claims["email"].(string)
username, _ := claims["username"].(string)
isAdmin, _ := claims["is_admin"].(bool)
@@ -73,6 +79,17 @@ func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
}
c.Set("isAdmin", isAdmin)
// Load user's roles and permissions
var user db.User
if h.DB != nil && userID > 0 {
if err := h.DB.Preload("Roles").First(&user, uint(userID)).Error; err != nil {
log.Printf("Error loading user roles: %v", err)
// Continue without roles if there's an error
} else {
c.Set("user", &user)
}
}
c.Next()
}
}
@@ -90,6 +107,51 @@ func (h *Handlers) AdminMiddleware() gin.HandlerFunc {
}
}
// PermissionMiddleware creates a middleware that checks for specific permissions
func (h *Handlers) PermissionMiddleware(requiredPermissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
// Get user from context
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found in context"})
c.Abort()
return
}
// Type assert to *db.User
u, ok := user.(*db.User)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"})
c.Abort()
return
}
// Check if user is admin (admins have all permissions)
isAdmin, exists := c.Get("isAdmin")
if exists && isAdmin.(bool) {
c.Next()
return
}
// Check each required permission
for _, permission := range requiredPermissions {
if !u.HasPermission(permission) {
// If it's an API request, return JSON
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
} else {
// For web requests, redirect to dashboard with error message
c.Redirect(http.StatusFound, "/dashboard?error=insufficient_permissions")
}
c.Abort()
return
}
}
c.Next()
}
}
// APIAuthMiddleware is a middleware function that checks if the API request is authenticated
func (h *Handlers) APIAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -129,11 +191,38 @@ func (h *Handlers) APIAuthMiddleware() gin.HandlerFunc {
return
}
// Set user information in the context
c.Set("userID", uint(claims["user_id"].(float64)))
c.Set("email", claims["email"].(string))
c.Set("username", claims["username"].(string))
c.Set("isAdmin", claims["is_admin"].(bool))
// Safely extract user ID
userIDFloat, ok := claims["user_id"].(float64)
if !ok || userIDFloat <= 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID in token"})
c.Abort()
return
}
userID := uint(userIDFloat)
c.Set("userID", userID)
// Extract other claims
if email, ok := claims["email"].(string); ok {
c.Set("email", email)
}
if username, ok := claims["username"].(string); ok {
c.Set("username", username)
}
if isAdmin, ok := claims["is_admin"].(bool); ok {
c.Set("isAdmin", isAdmin)
}
// Load user's roles and permissions for API requests
if h.DB != nil {
var user db.User
if err := h.DB.Preload("Roles").First(&user, userID).Error; err != nil {
log.Printf("Error loading user roles: %v", err)
// Continue without roles
} else {
c.Set("user", &user)
}
}
c.Next()
}
+151 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -127,12 +128,58 @@ func (h *Handlers) HandleCreateConfig(c *gin.Context) {
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
if err := h.DB.Create(&config).Error; err != nil {
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
log.Printf("Error beginning transaction: %v", tx.Error)
c.String(http.StatusInternalServerError, "Failed to begin transaction")
return
}
if err := tx.Create(&config).Error; err != nil {
tx.Rollback()
log.Printf("Error creating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create config: %v", err))
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": config.Name,
"source_type": config.SourceType,
"dest_type": config.DestinationType,
"source_path": config.SourcePath,
"dest_path": config.DestinationPath,
"skip_processed_files": *config.SkipProcessedFiles,
"archive_enabled": *config.ArchiveEnabled,
"delete_after_transfer": *config.DeleteAfterTransfer,
"source_passive_mode": *config.SourcePassiveMode,
"dest_passive_mode": *config.DestPassiveMode,
}
auditLog := db.AuditLog{
Action: "create",
EntityType: "config",
EntityID: config.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("Error creating audit log: %v", err)
c.String(http.StatusInternalServerError, "Failed to create audit log")
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("Error committing transaction: %v", err)
c.String(http.StatusInternalServerError, "Failed to commit transaction")
return
}
// Generate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to generate rclone config: %v", err)
@@ -225,12 +272,70 @@ func (h *Handlers) HandleUpdateConfig(c *gin.Context) {
// Preserve fields that shouldn't be updated
config.CreatedBy = oldConfig.CreatedBy
if err := h.DB.Save(&config).Error; err != nil {
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
log.Printf("Error beginning transaction: %v", tx.Error)
c.String(http.StatusInternalServerError, "Failed to begin transaction")
return
}
if err := tx.Save(&config).Error; err != nil {
tx.Rollback()
log.Printf("Error updating config: %v", err)
c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to update config: %v", err))
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": config.Name,
"source_type": config.SourceType,
"dest_type": config.DestinationType,
"source_path": config.SourcePath,
"dest_path": config.DestinationPath,
"skip_processed_files": *config.SkipProcessedFiles,
"archive_enabled": *config.ArchiveEnabled,
"delete_after_transfer": *config.DeleteAfterTransfer,
"source_passive_mode": *config.SourcePassiveMode,
"dest_passive_mode": *config.DestPassiveMode,
"previous_state": map[string]interface{}{
"name": oldConfig.Name,
"source_type": oldConfig.SourceType,
"dest_type": oldConfig.DestinationType,
"source_path": oldConfig.SourcePath,
"dest_path": oldConfig.DestinationPath,
"skip_processed_files": *oldConfig.SkipProcessedFiles,
"archive_enabled": *oldConfig.ArchiveEnabled,
"delete_after_transfer": *oldConfig.DeleteAfterTransfer,
"source_passive_mode": *oldConfig.SourcePassiveMode,
"dest_passive_mode": *oldConfig.DestPassiveMode,
},
}
auditLog := db.AuditLog{
Action: "update",
EntityType: "config",
EntityID: config.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("Error creating audit log: %v", err)
c.String(http.StatusInternalServerError, "Failed to create audit log")
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("Error committing transaction: %v", err)
c.String(http.StatusInternalServerError, "Failed to commit transaction")
return
}
// Regenerate rclone config file
if err := h.DB.GenerateRcloneConfig(&config); err != nil {
log.Printf("Warning: Failed to regenerate rclone config: %v", err)
@@ -271,11 +376,54 @@ func (h *Handlers) HandleDeleteConfig(c *gin.Context) {
return
}
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
// Create audit log before deletion
auditDetails := map[string]interface{}{
"name": config.Name,
"source_type": config.SourceType,
"dest_type": config.DestinationType,
"source_path": config.SourcePath,
"dest_path": config.DestinationPath,
"skip_processed_files": *config.SkipProcessedFiles,
"archive_enabled": *config.ArchiveEnabled,
"delete_after_transfer": *config.DeleteAfterTransfer,
"source_passive_mode": *config.SourcePassiveMode,
"dest_passive_mode": *config.DestPassiveMode,
}
auditLog := db.AuditLog{
Action: "delete",
EntityType: "config",
EntityID: config.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
return
}
// Delete config
if err := h.DB.Delete(&config).Error; err != nil {
if err := tx.Delete(&config).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete config: %v", err)})
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config deleted successfully"})
}
@@ -67,12 +67,16 @@ func (h *Handlers) HandleDashboard(c *gin.Context) {
}
}
// Get the rclone version
rcloneVersion := components.GetRcloneVersion()
data := components.DashboardData{
RecentJobs: recentHistory,
ActiveTransfers: int(totalJobs),
CompletedToday: int(completedJobs),
FailedTransfers: int(failedJobs),
Configs: configsMap,
RcloneVersion: rcloneVersion,
}
components.Dashboard(components.CreateTemplateContext(c), data).Render(c, c.Writer)
+467
View File
@@ -0,0 +1,467 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
"gorm.io/gorm"
)
// HandleDatabaseTools renders the database tools page
func (h *Handlers) HandleDatabaseTools(c *gin.Context) {
backups, err := h.GetBackupFiles()
if err != nil {
h.HandleError(c, http.StatusInternalServerError, "Database Error", "Failed to list backup files", err)
return
}
ctx := components.CreateTemplateContext(c)
_ = components.AdminDatabaseTools(ctx, backups).Render(ctx, c.Writer)
}
// GetBackupFiles retrieves a list of database backup files
func (h *Handlers) GetBackupFiles() ([]components.BackupFile, error) {
var backups []components.BackupFile
// Read the backup directory
files, err := os.ReadDir(h.BackupDir)
if err != nil {
return nil, err
}
// Filter and sort backup files (newest first)
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".db") {
continue
}
fileInfo, err := file.Info()
if err != nil {
continue
}
// Get file size in human-readable format
size := formatFileSize(fileInfo.Size())
backups = append(backups, components.BackupFile{
Name: file.Name(),
Size: size,
Created: fileInfo.ModTime(),
FullPath: filepath.Join(h.BackupDir, file.Name()),
})
}
// Sort backups by creation time (most recent first)
// Simple bubble sort for now, can be optimized for larger lists
for i := 0; i < len(backups); i++ {
for j := i + 1; j < len(backups); j++ {
if backups[i].Created.Before(backups[j].Created) {
backups[i], backups[j] = backups[j], backups[i]
}
}
}
return backups, nil
}
// HandleBackupDatabase creates a backup of the current database
func (h *Handlers) HandleBackupDatabase(c *gin.Context) {
// Close the existing database connection to ensure a clean backup
h.DB.Close()
// Generate a backup filename with timestamp
timestamp := time.Now().Format("2006-01-02-150405")
backupName := fmt.Sprintf("backup-%s.db", timestamp)
backupPath := filepath.Join(h.BackupDir, backupName)
// Copy the database file
err := copyFile(h.DBPath, backupPath)
if err != nil {
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup+attempt") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+create+backup&details="+err.Error())
return
}
// Reopen the database connection and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?status=Backup+created+successfully")
}
// HandleRestoreDatabase restores the database from a backup file
func (h *Handlers) HandleRestoreDatabase(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
// Handle file upload restore
file, err := c.FormFile("backup_file")
if err != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Invalid+backup+file")
return
}
// Save the uploaded file
tempPath := filepath.Join(h.BackupDir, "temp-"+file.Filename)
if err := c.SaveUploadedFile(file, tempPath); err != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+save+uploaded+file&details="+err.Error())
return
}
// Use the uploaded file for restoration
filename = "temp-" + file.Filename
}
// Validate that the backup file exists
backupPath := filepath.Join(h.BackupDir, filename)
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Backup+file+not+found")
return
}
// Close the database connection
h.DB.Close()
// Create a backup of the current database before restoring
currentBackupName := fmt.Sprintf("pre-restore-%s.db", time.Now().Format("2006-01-02-150405"))
currentBackupPath := filepath.Join(h.BackupDir, currentBackupName)
if err := copyFile(h.DBPath, currentBackupPath); err != nil {
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+pre-restore+backup+attempt") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+backup+current+database&details="+err.Error())
return
}
// Restore the database by copying the backup file
if err := copyFile(backupPath, h.DBPath); err != nil {
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+restore+attempt") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+restore+database&details="+err.Error())
return
}
// Reopen the database connection with the restored database and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+restore") {
return
}
// Clean up temp file if it was an upload
if strings.HasPrefix(filename, "temp-") {
os.Remove(backupPath)
}
c.Redirect(http.StatusSeeOther, "/admin/database?status=Database+restored+successfully")
}
// HandleDownloadBackup allows downloading a backup file
func (h *Handlers) HandleDownloadBackup(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
h.HandleBadRequest(c, "Missing filename", "No backup file specified for download")
return
}
// Validate the filename to prevent directory traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
h.HandleBadRequest(c, "Invalid filename", "The filename contains invalid characters")
return
}
// Set file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
h.HandleNotFound(c, "Backup file not found", "The requested backup file does not exist")
return
}
// Serve the file for download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Type", "application/octet-stream")
c.File(filePath)
}
// HandleDeleteBackup deletes a backup file
func (h *Handlers) HandleDeleteBackup(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Invalid+backup+file")
return
}
// Validate the filename to prevent directory traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Invalid+backup+filename")
return
}
// Set file path
filePath := filepath.Join(h.BackupDir, filename)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Backup+file+not+found")
return
}
// Delete the file
if err := os.Remove(filePath); err != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+delete+backup&details="+err.Error())
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?status=Backup+deleted+successfully")
}
// HandleRefreshBackups refreshes the backup list
func (h *Handlers) HandleRefreshBackups(c *gin.Context) {
c.Redirect(http.StatusSeeOther, "/admin/database")
}
// HandleVacuumDatabase optimizes the database
func (h *Handlers) HandleVacuumDatabase(c *gin.Context) {
// Check if DB is nil
if h.DB == nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Database+connection+is+not+available")
return
}
// Check if user is authenticated before proceeding
_, exists := c.Get("userID")
if !exists {
c.Redirect(http.StatusSeeOther, "/login")
return
}
// Create a backup before vacuum
timestamp := time.Now().Format("2006-01-02-150405")
backupName := fmt.Sprintf("pre-vacuum-%s.db", timestamp)
backupPath := filepath.Join(h.BackupDir, backupName)
// Close DB connection to make a clean backup
h.DB.Close()
if err := copyFile(h.DBPath, backupPath); err != nil {
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup+attempt") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+backup+before+vacuum&details="+err.Error())
return
}
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup") {
return
}
// Run vacuum command
result := h.DB.Exec("VACUUM")
if result.Error != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Database+optimization+failed&details="+result.Error.Error())
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?status=Database+optimized+successfully")
}
// reopenDatabaseWithAuth safely closes and reopens the database while preserving user authentication
func (h *Handlers) reopenDatabaseWithAuth(c *gin.Context, reason string) (success bool) {
// Store auth info from context
userID, userExists := c.Get("userID")
email, emailExists := c.Get("email")
username, usernameExists := c.Get("username")
isAdmin, adminExists := c.Get("isAdmin")
// First close the existing connection if it exists
if h.DB != nil {
h.DB.Close()
// Set to nil to avoid using a closed connection
h.DB = nil
}
// Make sure we wait a moment to ensure the file is released
time.Sleep(100 * time.Millisecond)
// Reopen database WITHOUT running migrations
newDB, err := db.ReopenWithoutMigrations(h.DBPath)
if err != nil {
c.Redirect(http.StatusSeeOther, fmt.Sprintf("/admin/database?error=Failed+to+reconnect+to+database+%s&details=%s", reason, err.Error()))
return false
}
// Verify the connection works by executing a simple query
var count int64
if err := newDB.Raw("SELECT 1").Scan(&count).Error; err != nil {
c.Redirect(http.StatusSeeOther, fmt.Sprintf("/admin/database?error=Database+connection+test+failed+%s&details=%s", reason, err.Error()))
return false
}
// Only after validation, assign the new database connection
h.DB = newDB
// Restore auth context
if userExists {
c.Set("userID", userID)
}
if emailExists {
c.Set("email", email)
}
if usernameExists {
c.Set("username", username)
}
if adminExists {
c.Set("isAdmin", isAdmin)
}
// If we had a user, attempt to reload their info
if userExists && h.DB != nil {
var user db.User
userIDValue, ok := userID.(uint)
if !ok {
// Try to convert from float64 (the JWT parser returns numbers as float64)
if userIDFloat, ok := userID.(float64); ok {
userIDValue = uint(userIDFloat)
}
}
if userIDValue > 0 {
result := h.DB.Preload("Roles").First(&user, userIDValue)
if result.Error == nil {
c.Set("user", &user)
} else {
// Log the error but continue - we still have basic user info from JWT
fmt.Printf("Error loading user roles after DB reconnect: %v\n", result.Error)
}
}
}
return true
}
// HandleClearJobHistory clears job history records
func (h *Handlers) HandleClearJobHistory(c *gin.Context) {
// Check if DB is nil
if h.DB == nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Database+connection+is+not+available")
return
}
// Check if user is authenticated before proceeding
_, exists := c.Get("userID")
if !exists {
c.Redirect(http.StatusSeeOther, "/login")
return
}
// Backup the database before making significant changes
timestamp := time.Now().Format("2006-01-02-150405")
backupName := fmt.Sprintf("pre-clear-history-%s.db", timestamp)
backupPath := filepath.Join(h.BackupDir, backupName)
// Close DB connection to make a clean backup
h.DB.Close()
if err := copyFile(h.DBPath, backupPath); err != nil {
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup+attempt") {
return
}
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+backup+before+clearing+history&details="+err.Error())
return
}
// Reopen DB and preserve auth
if !h.reopenDatabaseWithAuth(c, "after+backup") {
return
}
// Make sure we have a valid connection before executing the DELETE
var testCount int64
if err := h.DB.Raw("SELECT COUNT(*) FROM job_histories").Scan(&testCount).Error; err != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+verify+database+connection&details="+err.Error())
return
}
// Build a more robust query that handles the case where the column might not exist
// First check if created_at column exists
var createdAtExists int
columnCheckQuery := `SELECT COUNT(*) FROM pragma_table_info('job_histories') WHERE name = 'created_at'`
h.DB.Raw(columnCheckQuery).Scan(&createdAtExists)
var result *gorm.DB
if createdAtExists > 0 {
// If created_at exists, use both columns
result = h.DB.Exec("DELETE FROM job_histories WHERE start_time < datetime('now', '-30 day') OR created_at < datetime('now', '-30 day')")
} else {
// Otherwise just use start_time
result = h.DB.Exec("DELETE FROM job_histories WHERE start_time < datetime('now', '-30 day')")
}
if result.Error != nil {
c.Redirect(http.StatusSeeOther, "/admin/database?error=Failed+to+clear+job+history&details="+result.Error.Error())
return
}
// Log how many records were deleted
recordsDeleted := result.RowsAffected
logMessage := fmt.Sprintf("Deleted %d job history records", recordsDeleted)
fmt.Println(logMessage)
// Redirect to the database page with success message
c.Redirect(http.StatusSeeOther, "/admin/database?status=Job+history+cleared+successfully+"+logMessage)
}
// Helper function to copy a file
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
return destFile.Sync()
}
// Helper function to format file size in human-readable format
func formatFileSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
+76
View File
@@ -0,0 +1,76 @@
package handlers
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
)
// HandleNotFound handles 404 Not Found errors
func (h *Handlers) HandleNotFound(c *gin.Context, title string, message string) {
if title == "" {
title = "Page Not Found"
}
if message == "" {
message = "The page you are looking for does not exist."
}
h.HandleError(c, 404, title, message, nil)
}
// HandleBadRequest handles 400 Bad Request errors
func (h *Handlers) HandleBadRequest(c *gin.Context, title, message string) {
h.HandleError(c, 400, title, message, nil)
}
// HandleUnauthorized handles 401 Unauthorized errors
func (h *Handlers) HandleUnauthorized(c *gin.Context) {
ctx := components.CreateTemplateContext(c)
_ = components.UnauthorizedError(ctx).Render(ctx, c.Writer)
}
// HandleServerError handles 500 Internal Server errors
func (h *Handlers) HandleServerError(c *gin.Context, err error) {
var details string
if err != nil {
details = err.Error()
}
ctx := components.CreateTemplateContext(c)
_ = components.ServerError(ctx, details).Render(ctx, c.Writer)
}
// HandleError handles generic errors with custom title and message
func (h *Handlers) HandleError(c *gin.Context, code int, title, message string, err error) {
var details string
if err != nil {
details = err.Error()
}
ctx := components.CreateTemplateContext(c)
_ = components.ErrorPage(ctx, code, title, message, details).Render(ctx, c.Writer)
}
// RegisterErrorHandlers sets up custom error handlers for the router
func (h *Handlers) RegisterErrorHandlers(router *gin.Engine) {
// Register 404 handler
router.NoRoute(func(c *gin.Context) {
h.HandleNotFound(c, "", "")
})
// Register 405 handler (Method Not Allowed)
router.NoMethod(func(c *gin.Context) {
h.HandleError(c, 405, "Method Not Allowed",
fmt.Sprintf("The %s method is not supported for this resource.", c.Request.Method), nil)
})
// Register function to recover from panics
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
var err error
if rec, ok := recovered.(error); ok {
err = rec
} else {
err = fmt.Errorf("%v", recovered)
}
h.HandleServerError(c, err)
c.Abort()
}))
}
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
@@ -348,8 +347,8 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
data.TotalPages++
}
// Add HTMX request checking and conditional rendering
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
// Create template context using the helper function
ctx := components.CreateTemplateContext(c)
// Check if this is an HTMX request
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
@@ -627,7 +626,9 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
data.TotalPages++
}
ctx := context.WithValue(c.Request.Context(), userIDKey, userID)
// Create template context using the helper function
ctx := components.CreateTemplateContext(c)
c.Header("Content-Type", "text/html")
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
}
+156 -5
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
@@ -297,13 +298,56 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) {
// Clear the Config field to prevent GORM from creating a new config
job.Config = db.TransferConfig{}
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
log.Printf("HandleCreateJob: Failed to begin transaction: %v", tx.Error)
c.String(http.StatusInternalServerError, "Failed to begin transaction")
return
}
// Create the job
if err := h.DB.CreateJob(&job); err != nil {
if err := tx.Create(&job).Error; err != nil {
tx.Rollback()
log.Printf("HandleCreateJob: Error creating job: %v", err)
c.String(http.StatusInternalServerError, "Failed to create job")
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
"enabled": job.GetEnabled(),
"config_ids": configIDsList,
"webhook_enabled": job.GetWebhookEnabled(),
"notify_on_success": job.GetNotifyOnSuccess(),
"notify_on_failure": job.GetNotifyOnFailure(),
}
auditLog := db.AuditLog{
Action: "create",
EntityType: "job",
EntityID: job.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("HandleCreateJob: Error creating audit log: %v", err)
c.String(http.StatusInternalServerError, "Failed to create audit log")
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("HandleCreateJob: Error committing transaction: %v", err)
c.String(http.StatusInternalServerError, "Failed to commit transaction")
return
}
log.Printf("HandleCreateJob: Job successfully created with ID: %d", job.ID)
// Schedule the job with the scheduler
@@ -481,12 +525,64 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) {
// Clear the Config field to prevent GORM from updating or creating a new config
job.Config = db.TransferConfig{}
if err := h.DB.UpdateJob(&job); err != nil {
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
log.Printf("HandleUpdateJob: Failed to begin transaction: %v", tx.Error)
c.String(http.StatusInternalServerError, "Failed to begin transaction")
return
}
if err := tx.Save(&job).Error; err != nil {
tx.Rollback()
log.Printf("HandleUpdateJob: Error updating job: %v", err)
c.String(http.StatusInternalServerError, "Failed to update job")
return
}
// Create audit log entry
auditDetails := map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
"enabled": job.GetEnabled(),
"config_ids": configIDsList,
"webhook_enabled": job.GetWebhookEnabled(),
"notify_on_success": job.GetNotifyOnSuccess(),
"notify_on_failure": job.GetNotifyOnFailure(),
"previous_state": map[string]interface{}{
"name": oldJob.Name,
"schedule": oldJob.Schedule,
"enabled": oldJob.GetEnabled(),
"config_ids": oldJob.GetConfigIDsList(),
"webhook_enabled": oldJob.GetWebhookEnabled(),
"notify_on_success": oldJob.GetNotifyOnSuccess(),
"notify_on_failure": oldJob.GetNotifyOnFailure(),
},
}
auditLog := db.AuditLog{
Action: "update",
EntityType: "job",
EntityID: job.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
log.Printf("HandleUpdateJob: Error creating audit log: %v", err)
c.String(http.StatusInternalServerError, "Failed to create audit log")
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
log.Printf("HandleUpdateJob: Error committing transaction: %v", err)
c.String(http.StatusInternalServerError, "Failed to commit transaction")
return
}
log.Printf("HandleUpdateJob: Job successfully updated")
// Reschedule the job with the scheduler
@@ -519,15 +615,55 @@ func (h *Handlers) HandleDeleteJob(c *gin.Context) {
}
}
// Unschedule the job from the scheduler
h.Scheduler.UnscheduleJob(job.ID)
// Start a transaction
tx := h.DB.Begin()
if tx.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
// Create audit log before deletion
auditDetails := map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
"enabled": job.GetEnabled(),
"config_ids": job.GetConfigIDsList(),
"webhook_enabled": job.GetWebhookEnabled(),
"notify_on_success": job.GetNotifyOnSuccess(),
"notify_on_failure": job.GetNotifyOnFailure(),
}
auditLog := db.AuditLog{
Action: "delete",
EntityType: "job",
EntityID: job.ID,
UserID: userID,
Details: auditDetails,
Timestamp: time.Now(),
}
if err := tx.Create(&auditLog).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audit log"})
return
}
// Delete job
if err := h.DB.Delete(&job).Error; err != nil {
if err := tx.Delete(&job).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job"})
return
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
// Unschedule the job from the scheduler
h.Scheduler.UnscheduleJob(job.ID)
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
}
@@ -566,6 +702,21 @@ func (h *Handlers) HandleRunJob(c *gin.Context) {
}
}
// Create audit log for manual job run
auditLog := db.AuditLog{
Action: "run_manual",
EntityType: "job",
EntityID: job.ID,
UserID: userID,
Details: map[string]interface{}{"name": jobName, "job_id": job.ID},
Timestamp: time.Now(),
}
if err := h.DB.Create(&auditLog).Error; err != nil {
log.Printf("HandleRunJob: Warning - Failed to create audit log: %v", err)
// Continue anyway, as this is not critical to running the job
}
// Run the job immediately using the scheduler
if err := h.Scheduler.RunJobNow(job.ID); err != nil {
c.Header("Content-Type", "text/html")
+63 -25
View File
@@ -6,6 +6,9 @@ import (
// RegisterRoutes registers all the routes for the web interface
func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Register error handlers first
h.RegisterErrorHandlers(router)
// Public routes
router.GET("/", h.HandleHome)
router.GET("/login", h.HandleLoginPage)
@@ -84,35 +87,70 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Admin-only routes
admin := router.Group("/admin")
admin.Use(h.AuthMiddleware(), h.AdminMiddleware())
admin.Use(h.AuthMiddleware())
{
admin.GET("/users", h.HandleUsers)
admin.GET("/users/new", h.HandleNewUser)
admin.POST("/users", h.HandleCreateUser)
admin.DELETE("/users/:id", h.HandleDeleteUser)
admin.GET("/register", h.HandleRegisterPage)
admin.POST("/register", h.HandleRegister)
// Main admin dashboard - requires admin role
admin.GET("", h.AdminMiddleware(), h.HandleAdminDashboard)
// Admin tools routes
admin.GET("/tools", h.HandleAdminTools)
admin.POST("/backup-database", h.HandleBackupDatabase)
admin.POST("/restore-database", h.HandleRestoreDatabase)
admin.POST("/restore-database/:filename", h.HandleRestoreDatabaseByFilename)
admin.GET("/export-configs", h.HandleExportConfigs)
admin.GET("/export-jobs", h.HandleExportJobs)
admin.POST("/clear-job-history", h.HandleClearJobHistory)
admin.POST("/vacuum-database", h.HandleVacuumDatabase)
admin.GET("/download-backup/:filename", h.HandleDownloadBackup)
admin.DELETE("/delete-backup/:filename", h.HandleDeleteBackup)
admin.GET("/refresh-backups", h.HandleRefreshBackups)
// 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.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.DELETE("/:id", h.PermissionMiddleware("users.delete"), h.HandleDeleteUser)
userGroup.PUT("/:id/toggle-lock", h.PermissionMiddleware("users.edit"), h.AdminToggleLockUser)
}
// Log viewer routes
admin.GET("/logs/refresh", h.HandleRefreshLogs)
admin.GET("/logs/view/:fileName", h.HandleViewLog)
admin.GET("/logs/download/:fileName", h.HandleDownloadLog)
// Admin routes for role management
adminRoles := admin.Group("/roles")
adminRoles.Use(h.PermissionMiddleware("roles.admin"))
{
adminRoles.GET("", h.AdminRoles)
adminRoles.GET("/new", h.AdminNewRolePage)
adminRoles.GET("/:id/edit", h.AdminEditRolePage)
adminRoles.POST("", h.AdminCreateRole)
adminRoles.PUT("/:id", h.AdminUpdateRole)
adminRoles.DELETE("/:id", h.AdminDeleteRole)
}
// Audit log routes
auditGroup := admin.Group("/audit")
auditGroup.Use(h.PermissionMiddleware("audit.view"))
{
auditGroup.GET("", h.HandleAuditLogs)
auditGroup.GET("/export", h.PermissionMiddleware("audit.export"), h.HandleExportAuditLogs)
}
// System settings routes
settingsGroup := admin.Group("/settings")
settingsGroup.Use(h.PermissionMiddleware("system.settings"))
{
settingsGroup.GET("", h.HandleSettings)
settingsGroup.POST("/notifications", h.HandleCreateNotificationService)
settingsGroup.DELETE("/notifications/:id", h.HandleDeleteNotificationService)
settingsGroup.POST("/general", h.HandleSettings) // Placeholder for future implementation
settingsGroup.POST("/security", h.HandleSettings) // Placeholder for future implementation
}
// Database tools routes
dbGroup := admin.Group("/database")
dbGroup.Use(h.PermissionMiddleware("system.backup"))
{
dbGroup.GET("", h.HandleDatabaseTools)
dbGroup.POST("/backup-database", h.HandleBackupDatabase)
dbGroup.POST("/restore-database", h.PermissionMiddleware("system.restore"), h.HandleRestoreDatabase)
dbGroup.GET("/restore-database/:filename", h.PermissionMiddleware("system.restore"), h.HandleRestoreDatabase)
dbGroup.GET("/download-backup/:filename", h.HandleDownloadBackup)
dbGroup.POST("/delete-backup/:filename", h.HandleDeleteBackup)
dbGroup.GET("/refresh-backups", h.HandleRefreshBackups)
dbGroup.POST("/vacuum-database", h.HandleVacuumDatabase)
dbGroup.POST("/clear-job-history", h.HandleClearJobHistory)
}
// Email test route
admin.POST("/test-email", h.HandleTestEmail)
}
// API routes
+417
View File
@@ -0,0 +1,417 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
)
// NotificationService represents a notification service configuration
type NotificationService struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null"` // email, slack, webhook
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
Config map[string]string `json:"config" gorm:"-"`
ConfigJSON string `json:"-" gorm:"column:config"`
Description string `json:"description"`
EventTriggers string `json:"event_triggers" gorm:"column:event_triggers;default:'[]'"`
PayloadTemplate string `json:"payload_template" gorm:"column:payload_template"`
SecretKey string `json:"secret_key" gorm:"column:secret_key"`
RetryPolicy string `json:"retry_policy" gorm:"column:retry_policy;default:'simple'"`
LastUsed time.Time `json:"last_used" gorm:"column:last_used"`
SuccessCount int `json:"success_count" gorm:"column:success_count;default:0"`
FailureCount int `json:"failure_count" gorm:"column:failure_count;default:0"`
CreatedBy uint `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeSave converts Config map to JSON string for storage
func (n *NotificationService) BeforeSave() error {
configJSON, err := json.Marshal(n.Config)
if err != nil {
return err
}
n.ConfigJSON = string(configJSON)
return nil
}
// AfterFind converts JSON string back to Config map
func (n *NotificationService) AfterFind() error {
if n.ConfigJSON != "" {
return json.Unmarshal([]byte(n.ConfigJSON), &n.Config)
}
return nil
}
// HandleSettings handles GET /settings
func (h *Handlers) HandleSettings(c *gin.Context) {
// Check if the user has permission to view settings
if !h.checkPermission(c, "system.settings") {
c.Redirect(http.StatusFound, "/dashboard")
return
}
var notificationServices []NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,
SuccessCount: service.SuccessCount,
FailureCount: service.FailureCount,
})
}
data := components.SettingsData{
NotificationServices: componentServices,
}
ctx := h.CreateTemplateContext(c)
components.Settings(ctx, data).Render(ctx, c.Writer)
}
// HandleCreateNotificationService handles POST /settings/notifications
func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Check if the user has permission to manage settings
if !h.checkPermission(c, "system.settings") {
c.Redirect(http.StatusFound, "/dashboard")
return
}
// Parse form data
name := c.PostForm("name")
serviceType := c.PostForm("type")
description := c.PostForm("description")
isEnabled := c.PostForm("is_enabled") == "on"
// Validate required fields
if name == "" || serviceType == "" {
// Return to settings page with error message
h.handleSettingsWithError(c, "Name and type are required fields.")
return
}
// Create config map based on service type
config := make(map[string]string)
switch serviceType {
case "email":
config["smtp_host"] = c.PostForm("smtp_host")
config["smtp_port"] = c.PostForm("smtp_port")
config["smtp_username"] = c.PostForm("smtp_username")
config["smtp_password"] = c.PostForm("smtp_password")
config["from_email"] = c.PostForm("from_email")
case "slack":
config["webhook_url"] = c.PostForm("webhook_url")
config["channel"] = c.PostForm("channel")
case "webhook":
config["webhook_url"] = c.PostForm("webhook_url")
config["method"] = c.PostForm("method")
config["headers"] = c.PostForm("headers")
// Add the new webhook fields
// Create event triggers JSON array
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Marshal the event triggers to JSON
eventTriggersJSON, err := json.Marshal(eventTriggers)
if err != nil {
h.handleSettingsWithError(c, "Failed to process event triggers: "+err.Error())
return
}
// Create new notification service with additional fields
service := NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Config: config,
Description: description,
EventTriggers: string(eventTriggersJSON),
PayloadTemplate: c.PostForm("payload_template"),
SecretKey: c.PostForm("secret_key"),
RetryPolicy: c.PostForm("retry_policy"),
CreatedBy: c.GetUint("userID"),
}
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
log.Printf("Error creating notification service: %v", err)
h.handleSettingsWithError(c, "Failed to create notification service: "+err.Error())
return
}
// Create audit log
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"description": service.Description,
"event_triggers": eventTriggers,
"retry_policy": service.RetryPolicy,
"has_secret_key": service.SecretKey != "",
}
auditLog := db.AuditLog{
Action: "create",
EntityType: "notification_service",
EntityID: service.ID,
UserID: c.GetUint("userID"),
Details: auditDetails,
}
if err := h.DB.Create(&auditLog).Error; err != nil {
log.Printf("Error creating audit log: %v", err)
}
// Redirect back to settings page with success message
h.handleSettingsWithSuccess(c, "Notification service created successfully.")
return
default:
h.handleSettingsWithError(c, "Invalid notification service type.")
return
}
// Create new notification service
service := NotificationService{
Name: name,
Type: serviceType,
IsEnabled: isEnabled,
Config: config,
Description: description,
CreatedBy: c.GetUint("userID"),
}
// Save to database
if err := h.DB.Create(&service).Error; err != nil {
log.Printf("Error creating notification service: %v", err)
h.handleSettingsWithError(c, "Failed to create notification service: "+err.Error())
return
}
// Create audit log
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"description": service.Description,
}
auditLog := db.AuditLog{
Action: "create",
EntityType: "notification_service",
EntityID: service.ID,
UserID: c.GetUint("userID"),
Details: auditDetails,
}
if err := h.DB.Create(&auditLog).Error; err != nil {
log.Printf("Error creating audit log: %v", err)
}
// Redirect back to settings page with success message
h.handleSettingsWithSuccess(c, "Notification service created successfully.")
}
// HandleDeleteNotificationService handles DELETE /settings/notifications/:id
func (h *Handlers) HandleDeleteNotificationService(c *gin.Context) {
// Check if the user has permission to manage settings
if !h.checkPermission(c, "system.settings") {
c.Redirect(http.StatusFound, "/dashboard")
return
}
// Get service ID from path
serviceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
h.handleSettingsWithError(c, "Invalid notification service ID.")
return
}
// Find service to delete (for audit log)
var service NotificationService
if err := h.DB.First(&service, serviceID).Error; err != nil {
h.handleSettingsWithError(c, "Notification service not found.")
return
}
// Delete the service
if err := h.DB.Delete(&NotificationService{}, serviceID).Error; err != nil {
log.Printf("Error deleting notification service: %v", err)
h.handleSettingsWithError(c, "Failed to delete notification service: "+err.Error())
return
}
// Create audit log
auditDetails := map[string]interface{}{
"name": service.Name,
"type": service.Type,
"is_enabled": service.IsEnabled,
"description": service.Description,
}
auditLog := db.AuditLog{
Action: "delete",
EntityType: "notification_service",
EntityID: service.ID,
UserID: c.GetUint("userID"),
Details: auditDetails,
}
if err := h.DB.Create(&auditLog).Error; err != nil {
log.Printf("Error creating audit log: %v", err)
}
// Redirect back to settings page with success message
h.handleSettingsWithSuccess(c, "Notification service deleted successfully.")
}
// Helper function to check if user has a specific permission
func (h *Handlers) checkPermission(c *gin.Context, permission string) bool {
// If user is admin, they have all permissions
isAdmin, exists := c.Get("isAdmin")
if exists && isAdmin.(bool) {
return true
}
// Get user from context
userID := c.GetUint("userID")
if userID == 0 {
return false
}
// Check if user has the required permission
// h.DB.UserHasPermission undefined (type *db.DB has no field or method UserHasPermission)
// Load user with roles and check permission
var user db.User
if err := h.DB.Preload("Roles").First(&user, userID).Error; err != nil {
log.Printf("Error loading user: %v", err)
return false
}
return user.HasPermission(permission)
}
// handleSettingsWithError renders the settings page with an error message
func (h *Handlers) handleSettingsWithError(c *gin.Context, errorMessage string) {
var notificationServices []NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,
SuccessCount: service.SuccessCount,
FailureCount: service.FailureCount,
})
}
data := components.SettingsData{
NotificationServices: componentServices,
ErrorMessage: errorMessage,
}
ctx := h.CreateTemplateContext(c)
components.Settings(ctx, data).Render(ctx, c.Writer)
}
// handleSettingsWithSuccess renders the settings page with a success message
func (h *Handlers) handleSettingsWithSuccess(c *gin.Context, successMessage string) {
var notificationServices []NotificationService
if err := h.DB.Find(&notificationServices).Error; err != nil {
log.Printf("Error fetching notification services: %v", err)
}
// Convert to components.NotificationService
var componentServices []components.NotificationService
for _, service := range notificationServices {
// Parse event triggers from JSON string to string slice
var eventTriggers []string
if service.EventTriggers != "" {
if err := json.Unmarshal([]byte(service.EventTriggers), &eventTriggers); err != nil {
log.Printf("Error parsing event triggers: %v", err)
}
}
componentServices = append(componentServices, components.NotificationService{
ID: service.ID,
Name: service.Name,
Type: service.Type,
IsEnabled: service.IsEnabled,
Config: service.Config,
Description: service.Description,
EventTriggers: eventTriggers,
PayloadTemplate: service.PayloadTemplate,
SecretKey: service.SecretKey,
RetryPolicy: service.RetryPolicy,
SuccessCount: service.SuccessCount,
FailureCount: service.FailureCount,
})
}
data := components.SettingsData{
NotificationServices: componentServices,
SuccessMessage: successMessage,
}
ctx := h.CreateTemplateContext(c)
components.Settings(ctx, data).Render(ctx, c.Writer)
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -82,6 +82,12 @@ func main() {
log.Fatalf("Failed to create admin user: %v", err)
}
log.Printf("Default admin user created successfully")
// Assign admin role to admin user
if err := database.AssignRoleToUser(adminUser.ID, 1, 1); err != nil {
log.Fatalf("Failed to assign admin role to admin user: %v", err)
}
log.Printf("Admin role assigned to admin user successfully")
}
// Initialize scheduler
+48 -28
View File
@@ -83,43 +83,63 @@ function applyTheme(theme) {
}
// Apply dark theme
function applyDarkTheme(setClass = true) {
if (setClass) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
}
document.documentElement.style.colorScheme = 'dark';
function applyDarkTheme() {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
localStorage.theme = 'dark';
// Add transition for smooth theme switching
document.documentElement.style.transition = 'background-color 0.3s ease, color 0.3s ease';
// Apply dark theme to specific containers
const jobsContainer = document.getElementById('jobs-container');
const configsContainer = document.getElementById('configs-container');
if (jobsContainer) {
jobsContainer.classList.add('dark');
jobsContainer.style.backgroundColor = '#111827';
}
if (configsContainer) {
configsContainer.classList.add('dark');
configsContainer.style.backgroundColor = '#111827';
}
// Override any white backgrounds in card elements
document.querySelectorAll('.bg-white').forEach(function(element) {
element.classList.add('dark-mode-override');
element.classList.remove('bg-white');
element.classList.add('bg-gray-800');
});
updateThemeColors('dark');
// Update theme toggle icon
updateThemeToggleIcon('dark');
// Store user preference in localStorage as a backup
localStorage.setItem('theme', 'dark');
}
// Apply light theme
function applyLightTheme(setClass = true) {
if (setClass) {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
}
document.documentElement.style.colorScheme = 'light';
function applyLightTheme() {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
localStorage.theme = 'light';
// Add transition for smooth theme switching
document.documentElement.style.transition = 'background-color 0.3s ease, color 0.3s ease';
// Remove dark theme from specific containers
const jobsContainer = document.getElementById('jobs-container');
const configsContainer = document.getElementById('configs-container');
if (jobsContainer) {
jobsContainer.classList.remove('dark');
jobsContainer.style.backgroundColor = 'rgb(249, 250, 251)';
}
if (configsContainer) {
configsContainer.classList.remove('dark');
configsContainer.style.backgroundColor = 'rgb(249, 250, 251)';
}
// Restore white backgrounds
document.querySelectorAll('.dark-mode-override').forEach(function(element) {
element.classList.remove('dark-mode-override');
element.classList.remove('bg-gray-800');
element.classList.add('bg-white');
});
updateThemeColors('light');
// Update theme toggle icon
updateThemeToggleIcon('light');
// Store user preference in localStorage as a backup
localStorage.setItem('theme', 'light');
}
// Apply system theme based on user's OS preference