mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-10-23 22:40:25 +02:00
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:
1 parent
aa2149e2cf
commit
49a586db53
79 files changed
+11902
-10290
No files matched your search
+1
-1
@@ -81,4 +81,4 @@ backups/
|
||||
|
||||
|
||||
# Ignore binaries
|
||||
gomft
|
||||
gomft
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+636
-952
File diff suppressed because it is too large.
Load diff
+183
-203
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 © { 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 © { 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 © { 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬ificationServices).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(¬ificationServices).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(¬ificationServices).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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user