Compare commits

..
81 Commits
Author SHA1 Message Date
StarFleetCPTN dd7426a01f Merge pull request #62 from StarFleetCPTN/development
feat: Implement frontend asset build process and update Docker config…
2025-03-27 15:30:08 -07:00
StarFleetCPTN fde60b7d68 feat: Implement frontend asset build process and update Docker configuration
- Added a new build script using esbuild to bundle JavaScript and CSS assets, including Tailwind CSS and Font Awesome.
- Updated the Dockerfile to include a multi-stage build process for frontend assets, ensuring efficient image creation.
- Enhanced GitHub workflows to automate the installation of Node.js dependencies and build frontend assets during CI/CD.
- Introduced a .gitignore entry for the dist directory to prevent unnecessary files from being tracked.
- Improved caching and content type handling for static files served by the application.
2025-03-27 15:28:40 -07:00
StarFleetCPTN 297e5ca92b Merge pull request #59 from StarFleetCPTN/development
feat: Add notification service structure to new notification form
2025-03-26 19:07:08 -07:00
StarFleetCPTN 405b2e605e feat: Add notification service structure to new notification form
- Introduced a new struct for NotificationService within the notification form data, encompassing various configuration fields for services like Pushbullet, Ntfy, Gotify, and Pushover.
- This addition enhances the form's capability to manage detailed service configurations, improving user experience and flexibility in notification management.
2025-03-26 19:06:18 -07:00
StarFleetCPTN 1f94641dee Merge pull request #58 from StarFleetCPTN/development
Massive Changes
2025-03-26 17:08:00 -07:00
StarFleetCPTN 9249b84ddd chore: Update dependencies and enhance README documentation
- Removed unused dependencies from go.mod and go.sum, including testify and objx.
- Expanded the README to include detailed information on authentication providers and user management features, enhancing clarity on security and integration options.
- Organized user management section to better outline authentication options and security features.
2025-03-26 17:03:26 -07:00
StarFleetCPTN a7cd021ff2 refactor: Remove obsolete test files
- Deleted outdated test files for providers, authentication, password management, configuration, database, email, scheduler, and web handlers.
- Cleaned up the codebase by removing tests that are no longer relevant or have been replaced by new implementations.
- This cleanup helps maintain a more manageable and efficient testing environment.
2025-03-26 16:51:14 -07:00
StarFleetCPTN 8424e97cf8 feat: Add modal functionality for viewing audit log details
- Implemented a modal to display detailed information for audit logs.
- Added event listeners for showing and hiding the modal based on user interactions.
- Enhanced user experience by allowing users to view JSON-formatted details in the modal.
- Included functionality to close the modal when clicking outside of it.
2025-03-26 16:50:20 -07:00
StarFleetCPTN f128eff8de feat: Expand notification service configuration options
- Added new fields for Pushbullet, Ntfy, Gotify, and Pushover services, including API keys, device identifiers, and message templates.
- Updated the notification form to dynamically display existing values for configuration fields.
- Enhanced backend handlers to support the retrieval of additional service configuration data.
- Improved user experience by ensuring that the form reflects current settings for each notification service type.
2025-03-26 16:39:37 -07:00
StarFleetCPTN db1a0ad5f5 feat: Enhance notification services with delete confirmation dialog
- Added a confirmation dialog for deleting notification services to prevent accidental deletions.
- Updated the notifications management interface to include improved delete button functionality.
- Enhanced JavaScript event handling for delete requests, providing user feedback on success and error scenarios.
- Refactored notification service deletion logic to ensure accurate tracking and messaging during deletion operations.
2025-03-26 06:26:39 -07:00
StarFleetCPTN 8e1321d251 feat: Enhance notification services management
- Added new routes and handlers for creating and editing notification services.
- Implemented a dedicated page for adding new notification services with a comprehensive form.
- Updated the notifications management interface to include links for editing existing services.
- Enhanced backend logic to support the retrieval and updating of notification service configurations.
- Improved user experience with toast notifications for success and error messages during service management.
2025-03-25 20:58:30 -07:00
StarFleetCPTN ef0829aaa4 feat: Add support for multiple notification services
- Implemented Pushbullet, Ntfy, Gotify, and Pushover notification services in the notifications management system.
- Enhanced the user interface to include configuration fields for each new service type, including API keys, device identifiers, and message templates.
- Updated backend logic to handle sending notifications through the newly integrated services, including error handling for API responses.
- Added event trigger options for job start, completion, and error notifications for each service.
- Improved test notification functionality to verify configurations for all supported services.
2025-03-24 18:15:41 -07:00
StarFleetCPTN 4d393e17ab feat: Implement notification services management
- Added a new notifications management page with functionality to create, delete, and test notification services.
- Introduced a new template for displaying notification services, including forms for adding and configuring services.
- Updated backend handlers to support notification service operations and improved error handling.
- Enhanced the user interface with toast notifications for success and error messages related to notification actions.
- Refactored existing settings components to integrate with the new notifications management features.
2025-03-24 16:55:32 -07:00
StarFleetCPTN d35f7bfe53 feat: Add notifications management components
- Introduced new templates for displaying and managing user notifications, including pagination support.
- Implemented helper functions for formatting notification times and determining icon and background colors based on notification types.
- Enhanced the user interface with controls for marking notifications as read and adjusting the number of notifications displayed per page.
- Added a dropdown for quick access to notifications and a count badge for unread notifications.
- Included a help section to guide users on managing their notifications effectively.
2025-03-24 16:32:37 -07:00
StarFleetCPTN b91b0e8796 feat: Implement authentication provider management components
- Added new templates for managing authentication providers, including forms for creating and editing providers.
- Implemented backend logic to handle retrieval, creation, and deletion of authentication providers.
- Introduced new database migrations to support the storage of authentication provider data.
- Enhanced the user interface to display available authentication providers and their statuses.
- Added routes and handlers for managing authentication provider actions in the web application.
2025-03-24 14:51:38 -07:00
StarFleetCPTN 233f1779d8 feat: Implement command flag management for rclone configurations
- Added functionality to manage command flags and their values in the configuration form.
- Updated the database schema to include a new field for storing command flag values.
- Enhanced backend logic to process and store flag values for non-boolean flags during configuration creation and updates.
- Improved the user interface to dynamically require destination fields based on selected commands.
- Refactored related templates and handlers to support the new command flag management features.
2025-03-23 20:30:02 -07:00
StarFleetCPTN 0cebfcf1f5 feat: Integrate rclone command functionality into configuration management
- Added support for selecting rclone commands and their associated flags in the configuration form.
- Implemented backend logic to handle rclone command retrieval and flag management.
- Enhanced the database schema to include rclone command and flag fields in the transfer configuration.
- Created new migration scripts to add rclone-related tables and fields.
- Updated the scheduler to execute transfers using the selected rclone command and flags.
- Developed new handlers for rendering rclone command options and flags in the web interface.
- Improved error handling and logging for rclone command execution and configuration updates.
2025-03-23 16:41:49 -07:00
StarFleetCPTN fb3f834c36 feat: Enhance user management and role deletion functionality
- Added toast notifications for user actions, improving user feedback on operations such as creation, deletion, and updates.
- Implemented a confirmation dialog for user deletions, ensuring users are prompted before irreversible actions.
- Refactored user management templates to streamline the user interface and enhance usability.
- Improved error handling for user operations, providing clearer messages for success and failure scenarios.
- Updated JavaScript functions to manage user interactions and modal displays more effectively.
2025-03-23 13:16:18 -07:00
StarFleetCPTN 30d0725331 feat: Enhance user management and dashboard components
- Updated user management templates to improve user experience with a new layout and success/error message handling.
- Implemented password hashing for user creation and management, enhancing security.
- Refactored dashboard components to include better data presentation and responsiveness.
- Added dark mode support for various components, ensuring a consistent user interface across themes.
- Improved toast notification animations for better user feedback during actions.
2025-03-23 11:42:16 -07:00
StarFleetCPTN 3140d9e143 feat: Enhance notifications and pagination functionality
- Implemented pagination for user notifications, allowing users to navigate through their notifications more efficiently.
- Updated the notifications page to display the total count of notifications and provide controls for adjusting the number of notifications shown per page.
- Added backend support for fetching paginated notifications and counting total notifications for users.
- Improved the user interface for notifications, including a new layout and additional help sections to guide users on managing their notifications.
2025-03-23 09:54:06 -07:00
StarFleetCPTN db6259f8a3 feat: Implement configuration and job duplication functionality
- Added duplication feature for configurations and jobs, allowing users to create copies of existing entries.
- Introduced new buttons in the UI for duplicating configurations and jobs, enhancing user experience.
- Implemented backend logic to handle duplication requests, ensuring proper ownership checks and data integrity.
- Added notifications for successful duplication actions to inform users of the outcome.
- Updated relevant templates and handlers to support the new duplication functionality.
2025-03-22 22:19:05 -07:00
StarFleetCPTN 49a586db53 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.
2025-03-22 16:55:36 -07:00
StarFleetCPTN aa2149e2cf feat: Add admin database template component 2025-03-21 19:01:39 -07:00
StarFleetCPTN 8dac5b390c Merge pull request #52 from StarFleetCPTN/development
Admin Tools Fix
2025-03-20 21:30:19 -07:00
StarFleetCPTN faef3c2df9 Update Admin Tools Template to Improve JSON Placeholder Formatting
- Simplified the JSON placeholder in the webhook payload textarea for better readability and usability.
2025-03-20 21:29:25 -07:00
StarFleetCPTN 874c7ad69e Refactor Admin Tools Template and Handler Context
- Updated the AdminTools template to format JSON payloads for better readability.
- Modified the HandleAdminTools function to create a template context with user information before rendering the AdminTools view.
2025-03-20 21:23:59 -07:00
StarFleetCPTN 7de4f57fc3 Merge pull request #51 from StarFleetCPTN/development
Email and Webhook notification testing. Job order for multi configs. Debugging for scheduler
2025-03-20 20:46:11 -07:00
StarFleetCPTN 9a49d87777 Implement Job Configuration Ordering in Job Form
- Added functionality to allow users to select and reorder job configurations in both new and edit job forms.
- Introduced JavaScript logic to handle the display and ordering of selected configurations, including move up/down buttons.
- Updated backend to process and store the order of configurations when creating or updating jobs.
- Enhanced logging for job creation and update processes to include configuration order details.
2025-03-20 20:39:26 -07:00
StarFleetCPTN 73362793ad Add Webhook Notification Testing to Admin Tools
- Introduced a new section for testing webhook notifications within the admin tools.
- Added a form to input webhook URL, secret, custom headers, and payload for testing.
- Implemented backend logic to handle webhook test requests and display results.
- Created a toast notification component to show success or failure messages for webhook tests.
- Updated the AdminToolsData structure to include fields for webhook test results.
2025-03-20 18:49:32 -07:00
StarFleetCPTN d6bd471eb0 Add Email Testing Functionality to Admin Tools
- Introduced a new section in the admin tools for testing email configurations.
- Added a form to send test emails, including recipient, subject, and message fields.
- Implemented backend logic to handle test email requests and send emails using the configured SMTP server.
- Created a toast notification component to display success or failure messages for email tests.
- Updated routes to include a new endpoint for handling test email submissions.
2025-03-20 18:11:57 -07:00
StarFleetCPTN 1a1df435de Enhance logging in scheduler for better debugging
- Added debug logging for job scheduling, execution, and configuration processing.
- Included detailed log messages for cron expression validation and file transfer commands.
- Improved visibility into job history and webhook notifications with additional debug information.
2025-03-19 18:30:26 -07:00
StarFleetCPTN e031e67b3c Merge pull request #46 from StarFleetCPTN/development
Fix GetReleaseURL to dynamically use AppVersion in the release URL fo…
2025-03-19 05:19:11 -07:00
StarFleetCPTN 402072540f Fix GetReleaseURL to dynamically use AppVersion in the release URL format 2025-03-19 05:18:24 -07:00
StarFleetCPTN be19a5efde Update go-release.yml 2025-03-18 22:13:47 -07:00
StarFleetCPTN aa04c76e1f Merge pull request #45 from StarFleetCPTN/development
Enhance Docker and Build Configuration
2025-03-18 21:23:42 -07:00
StarFleetCPTN 1981764f80 Enhance Docker and Build Configuration
- Added support for build arguments in Dockerfile to specify UID, GID, version, build time, and commit hash.
- Updated docker-compose.yaml to utilize UID and GID for non-root user execution, improving file permission handling.
- Introduced entrypoint.sh script to manage user permissions and environment setup at container startup.
- Enhanced application logging to include version information during startup.
- Updated README with new instructions for running the application with specific user IDs and environment variables.
2025-03-18 21:23:15 -07:00
StarFleetCPTN 83be259754 Merge pull request #37 from StarFleetCPTN/development
Implement Two-Factor Authentication (2FA) features and enhancements
2025-03-17 19:26:31 -07:00
StarFleetCPTN 1995d19c3d Implement Two-Factor Authentication (2FA) features and enhancements
- Added configuration options for Two-Factor Authentication, including TOTP encryption key.
- Updated user profile components to manage 2FA status and backup codes.
- Implemented encryption and decryption of TOTP secrets using AES-256-GCM.
- Enhanced backup code generation and validation processes, storing hashed codes securely.
- Introduced new routes and handlers for managing backup codes and 2FA setup.
- Updated README with detailed instructions and security considerations for 2FA implementation.
2025-03-17 19:26:08 -07:00
StarFleetCPTN 2258bb1f70 Merge pull request #35 from StarFleetCPTN/development
Update README to include detailed Two-Factor Authentication (2FA) imp…
2025-03-17 05:25:33 -07:00
StarFleetCPTN 4254b529ef Update README to include detailed Two-Factor Authentication (2FA) implementation instructions and features. Added sections on setup process, login flow, and database changes related to 2FA support. 2025-03-17 05:24:53 -07:00
StarFleetCPTN a2971e3053 Merge pull request #34 from StarFleetCPTN/development
feat: Implement Two-Factor Authentication (2FA) functionality and Bug Fixes
2025-03-17 05:17:49 -07:00
StarFleetCPTN a7d912aa44 feat: Implement Two-Factor Authentication (2FA) functionality
- Added support for Two-Factor Authentication, including setup, verification, and disabling features.
- Introduced new components for 2FA setup and verification, enhancing user security.
- Updated user model to include fields for 2FA secret, status, and backup codes.
- Created migration to add necessary database fields for 2FA.
- Enhanced authentication handlers to manage 2FA processes during login and profile updates.
- Updated routes to include 2FA-related endpoints for setup and verification.
2025-03-17 05:17:03 -07:00
StarFleetCPTN 3dea48c691 Merge pull request #32 from StarFleetCPTN/development
refactor: Remove outdated migration for built-in auth fields and add …
2025-03-16 17:53:57 -07:00
StarFleetCPTN ef4bee88b8 refactor: Remove outdated migration for built-in auth fields and add new migration for updating Google Drive type
- Deleted the migration that updated the use_builtin_auth field to separate source and destination fields.
- Introduced a new migration to update source_type and destination_type from 'google_drive' to 'gdrive' in transfer_configs.
2025-03-16 17:50:43 -07:00
StarFleetCPTN 08d5f0edfc Merge pull request #31 from StarFleetCPTN/development
refactor: Update initial schema migration to correct table naming con…
2025-03-16 17:23:08 -07:00
StarFleetCPTN 1d43a7582d refactor: Update initial schema migration to correct table naming conventions
- Remove renaming logic for job_histories and password_histories tables, ensuring consistency with plural naming.
- Adjust CREATE TABLE statements to reflect the correct plural forms for job_histories and password_histories.
- Update drop table order to match the new naming conventions, maintaining foreign key constraints.
2025-03-16 17:14:14 -07:00
StarFleetCPTN 5e69aff21e Merge pull request #30 from StarFleetCPTN/development
chore: Revise README warning message for clarity
2025-03-16 16:30:39 -07:00
StarFleetCPTN 0c0c62b1f7 chore: Revise README warning message for clarity
- Replace the previous warning message with a more concise and clear statement regarding the active development status of GoMFT.
- Emphasize the potential for rapid changes in configurations and the importance of reviewing release notes before updates.
2025-03-16 16:29:18 -07:00
StarFleetCPTN d085bac227 Merge pull request #29 from StarFleetCPTN/development
New Logo, UI Updates, and Bug Fixes
2025-03-16 16:14:25 -07:00
StarFleetCPTN b1db53b89e feat: Refactor Google Drive and Google Photos authentication handling
- Separate built-in authentication options for source and destination in configuration forms.
- Update UI components to reflect changes in authentication handling for Google Drive and Google Photos.
- Implement path validation for local directories in the configuration forms, enhancing user experience.
- Revise README to clarify supported source and destination types, including updated terminology for Google Drive.
- Introduce new API endpoint for path validation, improving error handling and user feedback.
2025-03-16 16:12:42 -07:00
StarFleetCPTN 522699d96a refactor: Update UI components for improved styling and accessibility
- Change background and text colors in configuration forms to enhance visibility and consistency with dark mode.
- Update button classes and layout for better responsiveness and user experience.
- Revise authentication prompts and information sections for Google Drive and Google Photos to clarify the authentication process.
- Ensure all text elements are styled appropriately for both light and dark themes, improving overall accessibility.
2025-03-16 11:53:22 -07:00
StarFleetCPTN cab1de3306 feat: Update README and UI with new logo and warning message
- Add a new logo for GoMFT in SVG and PNG formats.
- Center the application title in the README and enhance the layout with a warning message about active development.
- Update the home page template to display the new logo prominently.
- Revise the screenshots section to improve visual presentation and clarity.
2025-03-16 11:06:07 -07:00
StarFleetCPTN 15058a7247 Merge pull request #26 from StarFleetCPTN/development
Added Google Drive and Google Photos and More Tests
2025-03-15 23:42:57 -07:00
StarFleetCPTN f1fd33c619 feat: Enhance README with Google Drive and Google Photos configuration details
- Add Google Drive and Google Photos as supported storage options in the features section.
- Include Google OAuth configuration parameters in the environment variable section.
- Update transfer configuration options to specify Google Photos and Google Drive specific settings.
- Improve documentation clarity for file options and performance settings.
2025-03-15 23:42:24 -07:00
StarFleetCPTN d68a80bd78 feat: Add Google Photos support for transfer configurations
- Introduce Google Photos as a source and destination option in the configuration forms.
- Implement authentication handling and configuration management for Google Photos.
- Update database schema and migrations to include new fields related to Google Photos.
- Enhance UI components to support Google Photos-specific settings and forms.
- Add tests for Google Photos integration, ensuring proper functionality and authentication flow.
- Limit concurrent transfers for Google Photos to ensure compliance with API restrictions.
2025-03-15 23:16:49 -07:00
StarFleetCPTN 8607f2098a feat: Integrate Google Drive support and enhance configuration handling
- Add Google Drive as a source and destination option in the configuration forms.
- Implement Google Drive authentication flow and token management.
- Update job and configuration handlers to support Google Drive-specific settings.
- Enhance UI components to include Google Drive configuration templates.
- Introduce new tests for Google Drive integration and ensure proper handling of authentication and configuration.
- Update database migrations to accommodate new fields related to Google Drive configurations.
2025-03-15 17:36:36 -07:00
StarFleetCPTN 0275de0f07 Merge pull request #25 from StarFleetCPTN/development
refactor: Clean up config creation handler by removing debug print st…
2025-03-14 23:24:13 -07:00
StarFleetCPTN a954023800 refactor: Clean up config creation handler by removing debug print statements
- Remove unnecessary print statements for form data and configuration details in the HandleCreateConfig function.
- Update handling of skipProcessedFiles to use a pointer for improved clarity and functionality.
2025-03-14 23:23:41 -07:00
StarFleetCPTN f11c03e22e Merge pull request #24 from StarFleetCPTN/development
Multi config jobs, Webhook notification, Mult thread support, and Testing
2025-03-14 18:59:36 -07:00
StarFleetCPTN 64323dd9ee feat: Add GoMFT Testing Guide and enhance concurrent transfer settings
- Introduce a comprehensive testing guide for the GoMFT application, detailing testing strategies, types, and best practices.
- Add new test utilities and examples for various components, including unit, integration, API, and webhook tests.
- Update the maximum concurrent transfers setting in the UI from 20 to 32 to support increased transfer capabilities.
2025-03-14 18:36:00 -07:00
StarFleetCPTN d5b0a686e0 feat: Implement webhook notifications and admin tools for job management
- Add webhook notification settings to job configuration, allowing users to enable notifications for job success and failure.
- Implement webhook payload structure and authentication using HMAC-SHA256 for secure communication.
- Enhance the admin tools with a log viewer and system management features, including database backup and log file access.
- Update README documentation to include details on webhook integration and admin tools.
- Introduce comprehensive tests for webhook functionality, ensuring correct payload delivery and header validation.
- Add database migrations to support new webhook fields in job configurations.
2025-03-14 18:22:59 -07:00
StarFleetCPTN 558e81c7e8 feat: Enhance configuration handling and UI for job management
- Update .gitignore to include new provider files and ensure proper tracking.
- Refactor job and config forms to support multiple configuration selections, improving user experience.
- Implement logic to handle the initialization of skipProcessedFiles with a default value.
- Add new provider form templates for better organization and management of source and destination configurations.
- Enhance tests for provider forms and job configurations to ensure robust functionality.
- Introduce nullable handling for skipProcessedFiles in the database schema and update related migrations.
- Improve error handling and validation in job creation and editing processes.
2025-03-14 16:30:43 -07:00
StarFleetCPTN d6fa0c1603 feat: Implement multi-configuration support for jobs
- Add support for multiple transfer configurations per job, allowing users to select one or more configurations.
- Update job creation and editing forms to handle multiple configuration selections with checkboxes.
- Enhance job processing logic to iterate through all associated configurations during execution.
- Introduce database migrations to add necessary fields for storing multiple configuration IDs.
- Update dashboard and history views to display configuration details for jobs.
- Refactor related templates and handlers to accommodate the new multi-configuration functionality.
2025-03-13 20:04:13 -07:00
StarFleetCPTN d1967b4402 feat: Add comprehensive tests for providers and scheduler functionalities
- Introduce new test file for providers, covering integration and rendering of source and destination components
- Implement tests for source and destination provider availability
- Enhance scheduler tests with new scenarios for job unscheduling, log rotation, and file processing history
- Add tests for output pattern processing with date and filename variables
- Ensure accessibility and dynamic rendering of provider forms in tests
2025-03-13 19:02:02 -07:00
StarFleetCPTN 3193bf5111 feat: Update dependencies and enhance job run details template
- Add new dependencies: `github.com/joho/godotenv`, `github.com/stretchr/testify`, and `gopkg.in/natefinch/lumberjack.v2`
- Remove indirect dependency on `github.com/joho/godotenv`
- Refactor JobRunDetails template to separate content rendering for improved testing
- Enhance error message display in JobRunDetails template
- Introduce new test files for JWT and password functionalities
- Add comprehensive tests for database operations and error handling
2025-03-13 18:40:40 -07:00
StarFleetCPTN a8b4588ecb feat: Implement comprehensive log viewer with advanced features
- Add new log viewer component in admin tools
- Support dynamic log file browsing and content display
- Implement custom scrollbar for log content
- Add log file refresh, download, and view capabilities
- Update environment variables for log configuration
- Enhance logging system with more flexible configuration options
2025-03-11 20:50:47 -07:00
StarFleetCPTN ed81b2c9be feat: Add multi-threaded file transfers and enhanced logging support
- Implement concurrent file transfer processing with configurable concurrency
- Add new `max_concurrent_transfers` column to transfer configurations
- Enhance scheduler to support multi-threaded file transfers
- Introduce advanced logging system with rotation and configurable log levels
- Update Docker Compose and documentation with new logging configuration options
- Modify directory structure to separate data, backups, and logs
- Add environment variables for comprehensive logging control
- Improve error handling and logging in file transfer processes
2025-03-11 18:00:10 -07:00
StarFleetCPTN 16b234b2d3 Merge pull request #18 from StarFleetCPTN/development
New File Metadata Page and other changes
2025-03-10 16:07:37 -07:00
StarFleetCPTN 5eac95818e feat: Add support for skipping processed files in transfer configs
- Introduce new `skip_processed_files` option for transfer configurations
- Add database migration to support the new column
- Update config form and UI to include skip processed files toggle
- Enhance scheduler to respect skip processed files setting
- Modify file processing logic to optionally skip previously processed files
- Add environment variable support for skip processed files configuration
2025-03-10 16:05:30 -07:00
StarFleetCPTN 6d0c215c38 docs: Update README with file metadata tracking details
- Add new section describing file metadata tracking features
- Include screenshot for file metadata view
- Expand feature list with comprehensive metadata management capabilities
- Update user guide with instructions for managing file metadata
- Remove Font Awesome icons from status badges in components
- Improve layout and styling for mobile responsiveness
2025-03-10 12:39:21 -07:00
StarFleetCPTN 31410984cb feat: Add partial search results rendering for file metadata
- Implement HandleFileMetadataSearchPartial handler for HTMX-powered search results
- Update file_metadata.templ to use new /files/search/partial endpoint
- Add HTMX request detection and browser redirect for search results
- Enhance search results pagination and filtering with partial rendering
- Improve user experience with dynamic, asynchronous search interactions
2025-03-09 22:10:22 -07:00
StarFleetCPTN 4b625b7120 refactor: Improve HTMX partial rendering for file metadata views
- Rename FileMetadataListContent to FileMetadataListPartial for consistency
- Add separate FileMetadataSearchContent template for search results
- Enhance handler logic to support HTMX partial rendering
- Implement more robust HTMX request detection
- Improve error handling and empty state rendering for file lists and search results
2025-03-09 22:02:02 -07:00
StarFleetCPTN 38461c7b7d feat: Add HTMX-powered dynamic loading for file lists and search results
- Implement partial rendering templates for file metadata list and search
- Add HTMX attributes for dynamic pagination and filtering
- Create loading indicators for HTMX requests
- Enhance user experience with smooth, asynchronous content updates
- Improve pagination and filtering interactions using HTMX
2025-03-09 20:28:00 -07:00
StarFleetCPTN 6545d26a93 feat: Enhance file deletion UX with custom confirmation dialogs
- Add FileMetadataDialog component for consistent delete confirmation
- Implement custom JavaScript handlers for file deletion events
- Add Notyf notifications for successful and failed deletions
- Improve delete button behavior in file list, details, and search views
- Enhance error handling and user feedback during file metadata deletion
2025-03-09 18:54:23 -07:00
StarFleetCPTN dfbcb021f5 feat: Enhance file listing and metadata extraction for rclone transfers
- Replace separate rclone size and list commands with single lsjson operation
- Improve file metadata extraction from JSON output
- Add support for hash and file size retrieval from remote sources
- Optimize file processing logic with more robust metadata handling
- Enhance error handling and logging for file listing and transfer processes
2025-03-09 11:37:21 -07:00
StarFleetCPTN db55c92909 feat: Prevent duplicate file transfers during job execution
- Add mechanism to track processed files within a single job run
- Ensure unique file processing by filtering out duplicate entries
- Improve logging for skipped duplicate files
- Enhance file transfer reliability and efficiency
2025-03-09 10:47:24 -07:00
StarFleetCPTN fcf6ee25a8 feat: Add file metadata tracking and search functionality
- Implement FileMetadata model to track processed files
- Create file metadata handlers for listing, searching, and viewing files
- Add file metadata routes and UI components
- Support advanced file search with multiple filters
- Enhance job execution to capture file metadata during transfers
- Implement file hash and duplicate detection logic
2025-03-09 10:22:57 -07:00
StarFleetCPTN 3b9ff3b6a8 chore: Refine config form and UI components
- Add x-init hook to set default form values in config form
- Adjust scrollbar hover color in layout
- Create marker files for providers, source, and destination packages
- Remove unnecessary error styling in local source form
2025-03-08 23:03:41 -08:00
StarFleetCPTN 43f1772038 feat: Modularize provider components for transfer configuration form
- Create provider-specific form templates for source and destination storage types
- Implement common form fields and type selection components
- Support multiple storage providers with consistent form design
- Update .gitignore to exclude generated provider files
2025-03-08 22:09:24 -08:00
StarFleetCPTN bcf55fb973 Merge pull request #17 from StarFleetCPTN/development
docs: Update supported storage options in README
2025-03-08 17:00:45 -08:00
StarFleetCPTN db4d0e018f docs: Update supported storage options in README
- Remove Azure Blob Storage and Google Cloud Storage
- Add NextCloud and WebDAV to supported storage list
2025-03-08 17:00:04 -08:00
169 changed files with 35767 additions and 6507 deletions
@@ -26,6 +26,41 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -61,5 +96,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+41
View File
@@ -27,6 +27,41 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -62,5 +97,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+34 -8
View File
@@ -27,6 +27,26 @@ jobs:
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Node.js dependencies
run: npm ci
- name: Build frontend assets
run: |
# Build JavaScript and CSS assets
node build.js
# Ensure the dist directory exists
mkdir -p static/dist
# Verify the build output
ls -la static/dist
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -57,23 +77,29 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for multiple platforms
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
# Linux builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
# macOS builds
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
# Windows builds
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
# Create checksums
cd dist
@@ -95,4 +121,4 @@ jobs:
generate_release_notes: true
draft: false
# The following line is not needed as we set permissions at workflow level
# token: ${{ secrets.GITHUB_TOKEN }}
# token: ${{ secrets.GITHUB_TOKEN }}
+26 -4
View File
@@ -46,19 +46,41 @@ Thumbs.db
components/*.go
!components/components.go
components/providers/*.go
!components/providers/providers.go
components/providers/source/*.go
!components/providers/source/source.go
components/providers/destination/*.go
!components/providers/destination/destination.go
components/providers/common/*.go
!components/providers/common/common.go
# Ignore the data directory
data/
# Ignore .env files
.env
*.env
# Ignore the tmp directory
tmp/
# Ignore the configs directory
configs/
# Ignore the backups directory
backups/
# Ignore Dirs
source/
destination/
archive/
/source/
/destination/
/archive/
# Ignore the dist directory
static/dist/
# Ignore binaries
gomft
gomft
+80 -8
View File
@@ -1,7 +1,41 @@
# Build frontend assets
FROM node:20-alpine AS frontend-builder
WORKDIR /app
# Copy all files needed for the build first
COPY package.json package-lock.json ./
COPY build.js ./
COPY static/ ./static/
COPY tailwind.config.js ./
# Debug: Show the contents of build.js
RUN echo "Contents of build.js:" && cat build.js
# Remove the build script from postinstall
RUN sed -i 's/"postinstall": "npm run build",//' package.json
# Install dependencies and build with verbose output
RUN npm ci && \
echo "Building frontend assets..." && \
node build.js && \
echo "Build complete. Contents of dist:" && \
ls -la static/dist/ && \
echo "Sample of app.js:" && \
head -n 10 static/dist/app.js && \
echo "Sample of app.css:" && \
head -n 10 static/dist/app.css
# Go build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Accept build arguments for version information
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG COMMIT=unknown
# Install build dependencies
RUN apk add --no-cache git build-base
@@ -12,14 +46,29 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source code
# Create static directory structure
RUN mkdir -p /app/static/dist
# Copy built frontend assets from frontend-builder BEFORE copying Go source
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
# Copy the rest of the static files
COPY static/ /app/static/
# Verify static files are in place before Go build
RUN echo "Verifying static files before Go build:" && \
ls -la /app/static/dist/
# Now copy the rest of the source code
COPY . .
# Generate template files from .templ files
RUN templ generate
# Build the application
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
# Compile the application with version information
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft
# Install rclone
RUN apk add --no-cache curl unzip && \
@@ -34,27 +83,50 @@ RUN apk add --no-cache curl unzip && \
# Create a smaller runtime image
FROM alpine:3.19
# Add arguments for UID and GID with defaults
ARG UID=1000
ARG GID=1000
ARG USERNAME=gomft
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite bash
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
&& apk add --no-cache --virtual .user-deps \
shadow curl xz
# Create user and group with specified IDs
RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binary from the builder stage
COPY --from=builder /app/gomft /app/
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
# Copy static files and configurations
COPY static/ /app/static/
# Copy components
COPY components/ /app/components/
# Create data directory
RUN mkdir -p /app/data/gomft
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create data and backup directories
RUN mkdir -p /app/data /app/backups
# Create a placeholder .env file with proper permissions
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions
RUN chmod +x /app/gomft
# Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app
# Expose the application port
EXPOSE 8080
# Use our entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["/app/gomft"]
+600 -78
View File
@@ -1,44 +1,84 @@
# GoMFT - Go Managed File Transfer
<p align="center">
<img src="static/img/logo.svg" alt="GoMFT Logo" width="200">
</p>
<h1 align="center">GoMFT - Go Managed File Transfer</h1>
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
---
## Screenshots
### Dashboard
![Dashboard Overview](screenshots/dashboard.gomft.png)
*Dashboard showing active transfers and system status*
![Dashboard Overview Dark](screenshots/dashboard.dark.gomft.png)
*Dashboard dark mode showing active transfers and system status*
### Configuration Interface
![Transfer Configuration](screenshots/new.configuration.gomft.png)
*Setting up transfer configurations with multiple storage options*
### Job Management
![Job Scheduling](screenshots/new.job.gomft.png)
*Scheduling transfers with flexible cron expressions*
### User Management
![User Management](screenshots/user.management.gomft.png)
*Create user accounts and manage them*
<table>
<tr>
<td width="33%">
<a href="screenshots/dashboard.gomft.png">
<img src="screenshots/dashboard.gomft.png" alt="Dashboard Overview" width="100%">
</a>
<p align="center"><em>Dashboard showing active transfers</em></p>
</td>
<td width="33%">
<a href="screenshots/dashboard.dark.gomft.png">
<img src="screenshots/dashboard.dark.gomft.png" alt="Dashboard Dark Mode" width="100%">
</a>
<p align="center"><em>Dashboard dark mode</em></p>
</td>
</tr>
</table>
---
## Features
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
- Google Drive
- Google Photos
- Amazon S3
- MinIO
- Backblaze B2
- Azure Blob Storage
- Google Cloud Storage
- NextCloud
- WebDAV
- SFTP
- FTP
- SMB/CIFS shares
- Local filesystem
- And more via rclone
- **Webhook Notifications**: Receive real-time notifications of job events:
- Configurable webhook URLs
- HMAC-SHA256 authentication with secrets
- Custom HTTP headers
- Selectable events (job success, job failure)
- Detailed JSON payload with job information
- **Multiple Notification Services**: Get job status updates through various notification channels:
- Email notifications with configurable SMTP settings
- Webhooks with authentication for custom integrations
- Pushbullet notifications with optional device targeting
- Ntfy.sh (both public and self-hosted) for simple push notifications
- Gotify server integration for self-hosted notifications
- Pushover notifications with customizable sounds and priorities
- Configurable message templates for all notification types
- Event-based triggers (job start, completion, errors)
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
- Process status (processed, archived, deleted)
- File size and hash information
- Advanced search and filtering capabilities
- Metadata retention for compliance and auditing
- Detailed file view with processing timestamps and job association
- Powerful filtering by status, filename, job, and date ranges
- Advanced search interface with multiple criteria
- Bulk management and record deletion capabilities
- Responsive design with mobile-friendly interface
- **Multi-threaded File Transfers**: Significantly improve performance with concurrent file processing:
- Configurable number of concurrent transfers (1-32) per job
- Automatic queue management to prevent system overload
- Independent configuration for each transfer job
- Optimized for both high-volume small files and large file transfers
- Maximizes bandwidth utilization for cloud storage providers
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
- **File Pattern Matching**: Support for file patterns to filter files during transfers
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
@@ -46,18 +86,30 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- **Transfer Configurations**: Full control over source and destination connection parameters
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
- **Security**: Role-based access control with admin-managed user accounts and secure password management
- **Authentication Providers**: Flexible authentication options:
- Built-in email/password authentication
- Authentik integration for enterprise SSO
- OpenID Connect (OIDC) support for standard identity providers
- OAuth2 integration for popular providers (Google, GitHub, etc.)
- Multiple provider support with fallback options
- Automatic user provisioning from external providers
- Role mapping from external identity providers
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
- **User Profile Management**: Personal settings including theme preferences
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
- **Portable Deployment**: Run on any platform that supports Docker or Go
---
## Prerequisites
- Go 1.21 or later
- rclone installed and configured
- SQLite 3
---
## Installation
### Standard Installation
@@ -88,19 +140,58 @@ docker pull starfleetcptn/gomft:latest
```
2. Run the container:
#### Basic run
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
starfleetcptn/gomft:latest
```
#### Run with specific user ID and group ID (using environment variables)
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
#### Or specify user IDs directly
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=1001 \
-e PGID=1001 \
starfleetcptn/gomft:latest
```
#### Using a .env file for configuration
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/.env:/app/.env \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
3. Access the web interface at `http://localhost:8080`
#### Docker Compose Example
For production deployments, you can use Docker Compose:
For production deployments, you can use Docker Compose with environment variables:
```yaml
version: '3'
@@ -113,11 +204,37 @@ services:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/data/gomft/backups
- ./backups:/app/backups
- ./.env:/app/.env
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- SERVER_ADDRESS=:8080
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- JWT_SECRET=change_this_to_a_secure_random_string
- BASE_URL=http://localhost:8080
- GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587
- EMAIL_FROM_EMAIL=gomft@example.com
- EMAIL_FROM_NAME=GoMFT
- EMAIL_ENABLE_TLS=true
- EMAIL_REQUIRE_AUTH=true
- EMAIL_USERNAME=smtp_username
- EMAIL_PASSWORD=smtp_password
- LOGS_DIR=/app/data/logs
- LOG_MAX_SIZE=10
- LOG_MAX_BACKUPS=5
- LOG_MAX_AGE=30
- LOG_COMPRESS=true
- LOG_LEVEL=info
# The user directive is no longer needed when using PUID/PGID environment variables
```
Save this as `docker-compose.yml` and run:
```bash
@@ -126,50 +243,91 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
---
## Configuration
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
```json
{
"server_address": ":8080",
"data_dir": "./data/gomft",
"backup_dir": "./data/gomft/backups",
"jwt_secret": "your-secret-key",
"base_url": "http://localhost:8080",
"email": {
"enabled": false,
"host": "smtp.example.com",
"port": 587,
"username": "user@example.com",
"password": "your-password",
"from_email": "gomft@example.com",
"from_name": "GoMFT",
"reply_to": "",
"enable_tls": true,
"require_auth": true
}
}
```ini
# Basic configuration
SERVER_ADDRESS=:8080
DATA_DIR=/app/data
BACKUP_DIR=/app/backups
JWT_SECRET=change_this_to_a_secure_random_string
BASE_URL=http://localhost:8080
# Google OAuth configuration (optional, for built-in authentication)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email configuration
EMAIL_ENABLED=true
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_FROM_EMAIL=gomft@example.com
EMAIL_FROM_NAME=GoMFT
EMAIL_REPLY_TO=
EMAIL_ENABLE_TLS=true
EMAIL_REQUIRE_AUTH=true
EMAIL_USERNAME=smtp_username
EMAIL_PASSWORD=smtp_password
# Two-Factor Authentication configuration
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
# UserID and GroupID
PUID=1000
PGID=1000
```
### Configuration Options
- `server_address`: The address and port to run the server on
- `data_dir`: Directory for storing application data
- `backup_dir`: Directory for storing database backups
- `jwt_secret`: Secret key for JWT token generation
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
- `email`: Email configuration settings for system notifications and password resets
- `enabled`: Set to `true` to enable email functionality
- `host`: SMTP server hostname
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
- `username`: Username for SMTP authentication
- `password`: Password for SMTP authentication
- `from_email`: Email address used as sender
- `from_name`: Name displayed as the sender
- `reply_to`: Optional reply-to email address
- `enable_tls`: Set to `true` to use TLS for secure email transmission
- `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
- `SERVER_ADDRESS`: The address and port to run the server on
- `DATA_DIR`: Directory for storing application data (database and configs)
- `BACKUP_DIR`: Directory for storing database backups
- `JWT_SECRET`: Secret key for JWT token generation
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
- Google OAuth configuration for built-in authentication:
- `GOOGLE_CLIENT_ID`: Your Google OAuth client ID
- `GOOGLE_CLIENT_SECRET`: Your Google OAuth client secret
- Email configuration settings for system notifications and password resets:
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
- `EMAIL_HOST`: SMTP server hostname
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
- `EMAIL_USERNAME`: Username for SMTP authentication
- `EMAIL_PASSWORD`: Password for SMTP authentication
- `EMAIL_FROM_EMAIL`: Email address used as sender
- `EMAIL_FROM_NAME`: Name displayed as the sender
- `EMAIL_REPLY_TO`: Optional reply-to email address
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
- Two-Factor Authentication (2FA) configuration:
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
- Should be exactly 32 bytes (characters) for optimal security
- If not set, a default development key will be used (not secure for production)
- If shorter than 32 bytes, it will be automatically padded (less secure)
- If longer than 32 bytes, it will be truncated to 32 bytes
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
### Logging Configuration
GoMFT provides configurable logging with rotation support through the following environment variables:
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
- `error`: Only show errors and critical issues
- `info`: Show errors and general operational information (default)
- `debug`: Show all messages including detailed debugging information
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
---
## Usage
@@ -189,6 +347,11 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
- Navigate to "Transfer Configs" section
- Configure source and destination locations with connection details
- Set file patterns and archive options as needed
- Configure performance settings:
- Set "Concurrent Transfers" slider to optimize throughput
- Use higher values (8-16) for many small files or fast networks
- Use lower values (1-4) for large files or limited bandwidth
- Consider source/destination system capabilities when setting
5. Create jobs using your configurations:
- Navigate to "Jobs" section
@@ -201,29 +364,163 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
- Check detailed transfer history with performance metrics
- View job run details including any error messages
7. Configure webhook notifications:
- Enable webhooks in job settings to receive notifications
- Provide a valid webhook URL where notifications will be sent
- Optionally set a webhook secret for HMAC-SHA256 signature verification
- Configure custom HTTP headers in JSON format if needed
- Choose notification triggers (job success, job failure, or both)
- Test your webhook integration with manual job runs
8. **Webhook Notifications**:
- **Webhook Integration**: Send notifications to external systems when jobs complete
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
- **Custom Headers**: Add custom HTTP headers to webhook requests
- **Flexible Configuration**: Configure different webhooks for different jobs
- **Event Selection**: Choose to send notifications on success, failure, or both
- **Detailed Payload**: Rich JSON payload with complete job execution details
9. **Multiple Notification Services**:
- **Pushbullet Integration**: Send notifications to your devices through Pushbullet
- Device targeting support for specific device delivery
- Customizable title and message templates
- API key-based authentication
- **Ntfy Integration**: Use public ntfy.sh or self-hosted ntfy server
- Topic-based routing of notifications
- Priority levels for different job events
- Optional username/password authentication for private servers
- Customizable title and message templates
- **Gotify Integration**: Send notifications to self-hosted Gotify servers
- Application token-based authentication
- Priority levels (1-10) for different notification importance
- Customizable title and message templates
- **Pushover Integration**: Professional notification delivery service
- Application and user key authentication
- Device targeting for selective delivery
- Sound selection for different notification types
- Priority levels from lowest to emergency
- Customizable title and message templates
- **Common Features**:
- Variable substitution in notification templates
- Job data access in templates (status, files, bytes, times)
- Event-based filtering (job start, completion, errors)
- Success/failure tracking for diagnostic purposes
10. Manage file metadata:
- Navigate to the "Files" section to view all processed files
- Use filters to quickly find files by status, job ID, or filename
- Click on any file to view detailed metadata including timestamps, size, and hash
- Use the advanced search page for complex queries with multiple criteria
- Delete file metadata records when no longer needed
- View files associated with specific jobs by navigating from the job details
11. Utilize admin tools (administrators only):
- Access the "Admin Tools" section from the navigation menu
- View system statistics and server information
- Create and manage database backups
- Browse and download system log files with the integrated log viewer
- Perform database maintenance and optimization tasks
- View webhook documentation and integration details
### User Management
GoMFT uses a role-based access control system:
GoMFT uses a role-based access control system with flexible authentication options:
- **Administrators**: Can create and manage users, access all features
- **Regular Users**: Can manage transfers and view history
User management features:
- Only administrators can create new user accounts
- User passwords are securely hashed with bcrypt
- Password history tracking prevents reuse of recent passwords
- Account lockout after multiple failed login attempts
- Self-service password reset via secure email links
- JWT-based authentication with tokens
- User theme preference settings (light/dark)
#### Authentication Options
1. **Built-in Authentication**:
- Email/password login with secure password hashing
- JWT-based session management
- Password history tracking
- Account lockout protection
- Self-service password reset
2. **External Authentication Providers**:
- **Authentik Integration**:
- Enterprise-grade SSO capabilities
- Automatic user provisioning
- Role synchronization
- Group mapping support
- Secure token exchange
- **OpenID Connect (OIDC)**:
- Standard-compliant identity provider support
- Automatic user creation and updates
- Role mapping from OIDC claims
- Multiple provider support
- Secure token validation
- **OAuth2 Providers**:
- Google authentication
- GitHub integration
- Other OAuth2-compliant providers
- Custom provider configuration
- Automatic profile synchronization
3. **Security Features**:
- Secure password hashing with bcrypt
- JWT-based authentication with tokens
- Password history tracking prevents reuse
- Account lockout after failed attempts
- Two-factor authentication support
- Session management and timeout
- Secure token storage and handling
4. **User Profile Management**:
- Theme preferences (light/dark mode)
- Profile information updates
- Password change functionality
- Two-factor authentication setup
- External account linking
### Two-Factor Authentication (2FA) Implementation
#### Overview
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
#### Features
- TOTP-based authentication (RFC 6238 compliant)
- QR code setup for easy enrollment
- Backup codes for account recovery
- Rate-limited verification attempts
- Secure secret storage
#### Database Changes
The following fields have been added to the `users` table:
- `two_factor_secret`: Stores the TOTP secret key
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
- `backup_codes`: Stores recovery backup codes
#### Setup Process
1. Navigate to `/profile/2fa/setup`
2. Scan the displayed QR code with your authenticator app
3. Enter the verification code to confirm setup
4. Save your backup codes in a secure location
#### Login Flow
1. Enter email and password as usual
2. If 2FA is enabled:
- Enter the 6-digit code from your authenticator app
- Alternatively, use a backup code if you can't access your authenticator
#### Security Considerations
- The TOTP secrets are encrypted using AES-256-GCM
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
- This key should be 32 bytes (characters) long and kept confidential
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
- For high-security deployments, store this key in a secure vault and inject it at runtime
### Transfer Configuration Options
1. **Source/Destination Types**:
- Google Drive
- Google Photos
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
- Backblaze B2
- SFTP
- FTP
- SMB/CIFS shares
@@ -232,22 +529,56 @@ User management features:
2. **Connection Options**:
- Host/server addresses
- Authentication (username/password or key files)
- OAuth2 authentication for Google services
- Port configurations
- Cloud credentials (access keys, secret keys)
- Bucket and region settings
- Custom endpoints
- Custom rclone flags
3. **File Options**:
3. **Google Photos Specific Options**:
- Read-only mode for safer operations
- Start year filter for historical photos
- Include/exclude archived media
- Album path configuration
- Built-in or custom OAuth authentication
4. **Google Drive Specific Options**:
- Folder ID for specific directory access
- Team/Shared Drive ID support
- Built-in or custom OAuth authentication
- Path-based navigation
5. **File Options**:
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
- Output patterns for dynamic naming
- Archive options for transferred files
- Skip already processed files to avoid duplicates
- Concurrent file transfers (configurable per job)
4. **Schedule Options**:
6. **Performance Options**:
- **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput
- Configurable concurrency level (1-32 concurrent transfers)
- Per-job concurrency settings to optimize for different storage types
- Automatic transfer queue management to prevent overloading systems
- Adaptive processing based on source/destination capabilities
7. **Schedule Options**:
- Cron expressions for flexible scheduling
- Manual execution
- Enable/disable schedules
8. **Notification Options**:
- **Email Notifications**: Receive job status updates via email
- **Webhook Notifications**: Integration with external systems
- **Pushbullet**: Push notifications to your devices
- **Ntfy**: Simple push notifications via ntfy.sh
- **Gotify**: Self-hosted notification server integration
- **Pushover**: Professional notification service
- Configure event triggers (start, complete, error)
- Customize notification message templates
- Selective notification based on job status
### Email Notifications
GoMFT supports email notifications for various features:
@@ -262,9 +593,92 @@ GoMFT supports email notifications for various features:
To configure email functionality:
1. Edit the `config.json` file and provide your SMTP server details
2. Set `"enabled": true` in the email configuration section
3. Ensure the `base_url` setting is configured correctly for your deployment
1. Edit the `.env` file and provide your SMTP server details
2. Set `EMAIL_ENABLED=true` in the email configuration section
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
### Webhook Integration
GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms.
#### Webhook Payload Structure
Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution:
```json
{
"event_type": "job_execution",
"job_id": 123,
"job_name": "Daily Backup",
"config_id": 456,
"config_name": "S3 to Local Backup",
"status": "completed",
"start_time": "2023-07-14T15:30:00Z",
"end_time": "2023-07-14T15:35:42Z",
"duration_seconds": 342,
"bytes_transferred": 1048576,
"files_transferred": 25,
"history_id": 789,
"source": {
"type": "s3",
"path": "my-bucket/data"
},
"destination": {
"type": "local",
"path": "/backups/data"
}
}
```
For failed transfers, additional error information is included:
```json
{
"status": "failed",
"error_message": "Permission denied accessing destination path"
}
```
#### Webhook Authentication
When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook:
1. Compute the HMAC-SHA256 of the raw request body using your shared secret
2. Compare it with the value in the `X-Hub-Signature-256` header
3. Process the webhook only if the signatures match
This ensures that webhook requests are authentic and haven't been tampered with.
### Admin Tools
GoMFT provides a comprehensive set of administrative tools for system management and monitoring:
#### Log Viewer
The Admin Tools panel includes an integrated log viewer with the following features:
- **Log File Browser**: View a list of all available log files in the system
- **Real-time Log Viewing**: View log file contents directly in the web interface
- **Refresh Function**: Update the log list and content with the latest information
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
- **Dark Mode Support**: Consistent theming with the rest of the application
- **Navigation**: Easily switch between different log files
This log viewer allows administrators to:
- Monitor system activity and diagnose issues without requiring server access
- View application logs, scheduler logs, and transfer logs in one place
- Track down errors and warning messages in real-time
#### Database Management
The Admin Tools interface also includes database management capabilities:
- Create and manage database backups
- Restore from previous backups
- Download backups for safekeeping
- View system statistics
- Optimize the database with maintenance tools
---
## Development
@@ -316,6 +730,8 @@ templ generate
air
```
---
## Contributing
1. Fork the repository
@@ -324,6 +740,112 @@ air
4. Push to the branch
5. Create a Pull Request
---
## Directory Structure
GoMFT uses the following directory structure:
- `/app/data`: Main application data directory
- Contains the SQLite database (`gomft.db`)
- Contains rclone configurations in `/app/data/configs`
- Contains log files in `/app/data/logs`
- `/app/backups`: Database backup directory
When using Docker, you should mount volumes to these locations:
```yaml
volumes:
- /host/path/data:/app/data # For all application data
- /host/path/backups:/app/backups # For database backups
```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
---
## Security Considerations
### Running as a Non-Root User
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
#### Benefits of Running as Non-Root
- **Improved Security**: Limits the potential damage if the container is compromised
- **Better File Permissions**: Files created by the container will match your host user permissions
- **Compliance**: Many security policies and best practices require containers to run as non-root
#### Methods to Run as Non-Root
1. **Using PUID/PGID environment variables (recommended)**:
```bash
# Using current user's ID
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
# Or in docker-compose.yml
environment:
- PUID=1000
- PGID=1000
```
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
2. **Using the `--user` flag with Docker run**:
```bash
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
```
3. **Using Docker Compose with environment variables for `user` directive**:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "${UID:-1000}:${GID:-1000}"
```
4. **Building a custom image with specified UID/GID**:
```yaml
services:
gomft:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
```
#### Environment Variables for User Management
| Variable | Description | Default |
|----------|-------------|---------|
| `PUID` | User ID to run as | Built-in user ID (1000) |
| `PGID` | Group ID to run as | Built-in group ID (1000) |
| `USERNAME` | Username to use | `gomft` |
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
#### Volume Permissions
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
```bash
# Create directories with correct ownership
mkdir -p data backups
chown -R $(id -u):$(id -g) data backups
# Or adjust permissions to allow the container user to write
mkdir -p data backups
chmod -R 777 data backups # Less secure, but easier for testing
```
---
## License
MIT License - see LICENSE file for details
[MIT License](LICENSE) - see the full license terms
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
The gopher design is from https://github.com/egonelbre/gophers.
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
+122
View File
@@ -0,0 +1,122 @@
import * as esbuild from 'esbuild';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isWatch = process.argv.includes('--watch');
// Ensure the dist directory exists
const distDir = path.join(__dirname, 'static', 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Copy Font Awesome files
const fontAwesomeSrcDir = path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free');
const fontAwesomeDestDir = path.join(distDir, 'fontawesome');
// Copy CSS files
const cssFiles = [
'css/all.min.css',
'css/fontawesome.min.css',
'css/solid.min.css',
'css/regular.min.css',
'css/brands.min.css'
];
cssFiles.forEach(file => {
const srcFile = path.join(fontAwesomeSrcDir, file);
const destFile = path.join(fontAwesomeDestDir, file);
const destDir = path.dirname(destFile);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (fs.existsSync(srcFile)) {
fs.copyFileSync(srcFile, destFile);
}
});
// Copy webfonts
const webfontsSrcDir = path.join(fontAwesomeSrcDir, 'webfonts');
const webfontsDestDir = path.join(fontAwesomeDestDir, 'webfonts');
if (!fs.existsSync(webfontsDestDir)) {
fs.mkdirSync(webfontsDestDir, { recursive: true });
}
fs.readdirSync(webfontsSrcDir).forEach(file => {
fs.copyFileSync(
path.join(webfontsSrcDir, file),
path.join(webfontsDestDir, file)
);
});
const commonConfig = {
sourcemap: true,
minify: true,
bundle: true,
platform: 'browser',
target: ['es2020'],
};
async function buildTailwind() {
console.log('Building Tailwind CSS...');
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --minify');
}
async function build() {
try {
// Build vendor JavaScript bundle (CDN dependencies)
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/vendor.js'],
outfile: 'static/dist/vendor.js',
format: 'iife',
});
// Build application JavaScript
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/app.js'],
outfile: 'static/dist/app.js',
format: 'iife',
});
// Build initialization JavaScript
await esbuild.build({
...commonConfig,
entryPoints: ['static/js/init.js'],
outfile: 'static/dist/init.js',
format: 'iife',
});
// Build CSS with Tailwind
await buildTailwind();
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
if (isWatch) {
// Watch mode
console.log('Starting watch mode...');
const ctx = await esbuild.context(commonConfig);
await ctx.watch();
// Watch Tailwind CSS
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --watch');
console.log('Watching for changes...');
} else {
// Single build
build();
}
+125
View File
@@ -0,0 +1,125 @@
package components
import (
"context"
)
// AdminLayout renders the main admin dashboard
templ AdminLayout(ctx context.Context) {
@LayoutWithContext("Administration", ctx) {
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
<!-- Sidebar Navigation -->
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="p-4 overflow-y-auto">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
<ul class="space-y-2">
<li>
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Dashboard
</a>
</li>
<li>
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
User Management
</a>
</li>
<li>
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Role Management
</a>
</li>
<li>
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Audit Logs
</a>
</li>
<li>
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
Database Tools
</a>
</li>
<li>
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
System Settings
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<div class="flex-1 p-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Statistics Cards -->
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Users</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">1,234</p>
</div>
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900">
<i class="fas fa-users text-blue-500 w-6 h-6 dark:text-blue-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">42</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-signal text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">System Health</p>
<p class="text-2xl font-bold text-green-500 dark:text-green-400">98%</p>
</div>
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
<i class="fas fa-heartbeat text-green-500 w-6 h-6 dark:text-green-300"></i>
</div>
</div>
</div>
</div>
<!-- Recent Activity Table -->
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Example Rows -->
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">admin@example.com</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Updated user permissions</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">2024-02-20 14:32:10</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
}
}
+384
View File
@@ -0,0 +1,384 @@
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>
// Modal functionality
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('details-modal');
const detailsContent = document.getElementById('details-content');
// Handle modal show/hide
document.querySelectorAll('[data-modal-target="details-modal"]').forEach(button => {
button.addEventListener('click', function() {
const details = this.getAttribute('data-details');
detailsContent.textContent = JSON.stringify(JSON.parse(details), null, 2);
modal.classList.remove('hidden');
});
});
document.querySelectorAll('[data-modal-hide="details-modal"]').forEach(button => {
button.addEventListener('click', function() {
modal.classList.add('hidden');
});
});
// Close modal when clicking outside
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.classList.add('hidden');
}
});
});
</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)
}
+442
View File
@@ -0,0 +1,442 @@
package components
import (
"context"
"time"
)
// BackupFile represents a database backup file
type BackupFile struct {
Name string
Size string
Created time.Time
FullPath string
}
// AdminDatabaseTools renders the database tools page with backup and restore functionality
templ AdminDatabaseTools(ctx context.Context, backups []BackupFile) {
@LayoutWithContext("Database Tools", ctx) {
<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 Tools
</h1>
<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>
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- 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>
</div>
<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 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
</button>
</form>
</div>
<!-- Clear Job History 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-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>
</div>
<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 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
</button>
</form>
</div>
<!-- Export Configuration 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-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>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Export Configuration</h3>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">Export system configurations and jobs for backup or migration.</p>
<div class="flex space-x-2">
<a href="/admin/database/export-configs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
<i class="fas fa-cogs mr-2"></i>
Export Configs
</a>
<a href="/admin/database/export-jobs" class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-green-700 dark:hover:bg-green-800 text-center">
<i class="fas fa-tasks mr-2"></i>
Export Jobs
</a>
</div>
</div>
</div>
<!-- Database Backup List -->
<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/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>
</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">Backup Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Created</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(backups) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
No backups found. Create your first backup to get started.
</td>
</tr>
} else {
for _, backup := range backups {
<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">{ backup.Name }</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">{ backup.Size }</td>
<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">
<button type="button"
data-modal-target="confirm-restore-modal"
data-modal-toggle="confirm-restore-modal"
data-backup-name={ backup.Name }
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>
<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>
<button type="button"
data-modal-target="confirm-delete-modal"
data-modal-toggle="confirm-delete-modal"
data-backup-name={ backup.Name }
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>
<!-- 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 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">
<label class="flex flex-col w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400"><span class="font-semibold">Click to upload</span> or drag and drop</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SQLite backup file</p>
</div>
<input type="file" name="backup_file" class="hidden" accept=".db,.sqlite,.backup" />
</label>
</div>
</div>
<div class="flex items-center justify-end">
<button type="submit" class="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-800">
<i class="fas fa-undo-alt mr-2"></i>
Restore Database
</button>
</div>
</form>
</div>
</div>
<!-- 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>
<li>Clear job history operations cannot be undone</li>
</ul>
</div>
</div>
</div>
</div>
</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 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-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/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
</button>
<button type="button" data-modal-hide="confirm-restore-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>
<!-- 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 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-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">
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>
// 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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
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/database/restore-database/${backupName}`;
// Show the modal
const modal = document.getElementById('confirm-restore-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
});
});
// Handle delete modal
const deleteButtons = document.querySelectorAll('[data-modal-target="confirm-delete-modal"]');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const backupName = this.getAttribute('data-backup-name');
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');
});
});
// File upload preview
const fileInput = document.querySelector('input[type="file"]');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const fileName = e.target.files[0]?.name || 'No file selected';
const fileLabel = this.parentElement.querySelector('p.mb-2');
if (fileLabel) {
fileLabel.textContent = fileName;
}
});
}
// Add form submission events
const backupForm = document.getElementById('backup-form');
if (backupForm) {
backupForm.addEventListener('submit', function(e) {
console.log('Backup form submitted');
});
}
const vacuumForm = document.getElementById('vacuum-form');
if (vacuumForm) {
vacuumForm.addEventListener('submit', function(e) {
console.log('Vacuum form submitted');
});
}
const clearJobForm = document.getElementById('clear-job-form');
if (clearJobForm) {
clearJobForm.addEventListener('submit', function(e) {
console.log('Clear job form submitted');
});
}
});
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('database-tools-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('database-tools-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+175
View File
@@ -0,0 +1,175 @@
package components
import (
"context"
"fmt"
"strings"
)
type RoleFormData struct {
Role *Role
IsNew bool
ErrorMessage string
AllPermissions []string
}
templ AdminRoleForm(ctx context.Context, data RoleFormData) {
@LayoutWithContext(getFormTitle(data.IsNew), ctx) {
<div class="mb-6">
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="/admin" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
<i class="fas fa-shield-alt w-4 h-4 mr-2"></i>
Admin
</a>
</li>
<li>
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<a href="/admin/roles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">Roles</a>
</div>
</li>
<li aria-current="page">
<div class="flex items-center">
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">
if data.IsNew {
New Role
} else {
Edit Role
}
</span>
</div>
</li>
</ol>
</nav>
</div>
if data.ErrorMessage != "" {
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400" role="alert">
<span class="font-medium">Error!</span> { data.ErrorMessage }
</div>
}
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
<div class="p-6 space-y-6">
<form method="POST" action={ getFormAction(data.IsNew, data.Role) }>
if !data.IsNew {
<input type="hidden" name="_method" value="PUT"/>
}
<!-- Role Name -->
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Role Name</label>
<input
type="text"
id="name"
name="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Enter role name"
required
if !data.IsNew {
value={ data.Role.Name }
}
/>
</div>
<!-- Role Description -->
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
<textarea
id="description"
name="description"
rows="3"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Enter role description"
>
if !data.IsNew {
{ data.Role.Description }
}
</textarea>
</div>
<!-- Permissions -->
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Permissions</label>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, perm := range data.AllPermissions {
<div class="flex items-center">
<input
type="checkbox"
id={ "perm_" + perm }
name="permissions[]"
value={ perm }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if !data.IsNew && hasPermission(data.Role, perm) {
checked
}
/>
<label for={ "perm_" + perm } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{ formatPermissionLabel(perm) }
</label>
</div>
}
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center justify-end space-x-3">
<a href="/admin/roles" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</a>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if data.IsNew {
Create Role
} else {
Update Role
}
</button>
</div>
</form>
</div>
</div>
}
}
// Helper functions
func getFormTitle(isNew bool) string {
if isNew {
return "New Role"
}
return "Edit Role"
}
func getFormAction(isNew bool, role *Role) templ.SafeURL {
if isNew {
return "/admin/roles"
}
return templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID))
}
func hasPermission(role *Role, permission string) bool {
if role == nil || role.Permissions == nil {
return false
}
for _, p := range role.Permissions {
if p == permission {
return true
}
}
return false
}
func formatPermissionLabel(permission string) string {
// Convert permission strings like "users.create" to "Create Users"
parts := strings.Split(permission, ".")
if len(parts) != 2 {
return permission
}
resource := strings.Title(parts[0])
action := strings.Title(parts[1])
return action + " " + resource
}
+316
View File
@@ -0,0 +1,316 @@
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) {
<!-- Toast container for status messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<!-- 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 {
<!-- Dialog for this role -->
@RoleDialog(
fmt.Sprintf("delete-role-dialog-%d", role.ID),
"Delete Role",
fmt.Sprintf("Are you sure you want to delete role \"%s\"? This action cannot be undone.", role.Name),
"bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700",
"Delete Role",
"delete",
role.ID,
role.Name,
)
<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 }
onclick={ showModal(fmt.Sprintf("delete-role-dialog-%d", role.ID)) }
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>
<script>
// 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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
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');
});
});
// Check for URL parameters after page load to show success/error messages
const urlParams = new URLSearchParams(window.location.search);
const successMsg = urlParams.get('success');
const errorMsg = urlParams.get('error');
// Show success toast if parameter exists
if (successMsg) {
showToast(decodeURIComponent(successMsg), 'success');
}
// Show error toast if parameter exists
if (errorMsg) {
showToast(decodeURIComponent(errorMsg), 'error');
}
// Show error from the data if it exists
if (document.querySelector('[data-error-message]')) {
const errorMsg = document.querySelector('[data-error-message]').getAttribute('data-error-message');
if (errorMsg) {
showToast(errorMsg, 'error');
}
}
// Set up window variables to track deletions
window.lastDeletedRole = null;
window.isRoleDeletingRequest = false;
});
// Handle role deletion
document.addEventListener('DOMContentLoaded', function() {
const confirmDeleteButtons = document.querySelectorAll('[id^="delete-role-btn-"]');
confirmDeleteButtons.forEach(button => {
button.addEventListener('click', function() {
const roleId = this.getAttribute('data-role-id');
const roleName = this.getAttribute('data-role-name');
// Store deletion info for event tracking
window.lastDeletedRole = {
id: roleId,
name: roleName
};
// Send DELETE request
fetch(`/admin/roles/${roleId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (response.ok) {
// Show success toast
showToast(`Role "${roleName}" deleted successfully`, 'success');
// Reload page on success after a short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
// Handle error
return response.json().then(data => {
showToast(`Error: ${data.error || 'Failed to delete role'}`, 'error');
}).catch(() => {
showToast(`Error deleting role "${roleName}"`, 'error');
});
}
})
.catch(error => {
console.error('Error:', error);
showToast(`Error: ${error.message || 'Failed to connect to server'}`, 'error');
});
});
});
});
</script>
<!-- Store error message if it exists -->
if data.Error != "" {
<div data-error-message={ data.Error } class="hidden"></div>
}
}
}
// RoleDialog for confirmation actions
templ RoleDialog(id string, title string, message string, confirmClass string, confirmText string, action string, roleID uint, roleName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="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">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<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">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<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">{ message }</h3>
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
id={ fmt.Sprintf("delete-role-btn-%d", roleID) }
data-role-id={ fmt.Sprint(roleID) }
data-role-name={ roleName }
onclick={ triggerRoleDelete(id, roleID, roleName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } 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>
</div>
</div>
</div>
</div>
}
script triggerRoleDelete(dialogId string, roleID uint, roleName 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.lastDeletedRole = {
id: roleID,
name: roleName
};
// Add custom marker to track this deletion
window.isRoleDeletingRequest = true;
}
-578
View File
@@ -1,578 +0,0 @@
package components
import (
"context"
"fmt"
"time"
)
type BackupFile struct {
Name string
Size string
ModTime time.Time
}
type AdminToolsData struct {
JobHistoryCount int
DatabaseSize string
LastBackupTime *time.Time
BackupCount int
SystemUptime string
ActiveJobs int
TotalConfigs int
TotalJobs int
TotalUsers int
DatabasePath string
BackupPath string
MaintenanceMessage string
BackupFiles []BackupFile
}
// Dialog component for confirmation dialogs
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction 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>
<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={ hideDialog(id) }>
Cancel
</button>
if formId != "" {
<button
type="button"
class={ confirmClass }
onclick={ submitFormAndHideDialog(formId, id) }>
{ confirmText }
</button>
} else {
<button
type="button"
class={ confirmClass }
hx-target={ targetAction }
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
script hideDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script submitFormAndHideDialog(formId string, dialogId string) {
document.getElementById(formId).submit();
document.getElementById(dialogId).classList.add("hidden");
}
script showDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
// Backup dialog component specifically for restore and delete actions
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName 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-5xl"></i>
</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-center space-x-3">
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
Cancel
</button>
if action == "restore" {
<button
type="button"
class="btn-warning"
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
hx-swap="none"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
} else if action == "delete" {
<button
type="button"
class="btn-danger"
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
hx-target="closest tr"
hx-swap="delete"
onclick={ hideDialog(id) }>
{ confirmText }
</button>
}
</div>
</div>
</div>
}
templ AdminTools(ctx context.Context, data AdminToolsData) {
@LayoutWithContext("Admin Tools", 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-tools mr-2 text-primary-600 dark:text-primary-400"></i>
Admin Tools
</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>
if data.MaintenanceMessage != "" {
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
{ data.MaintenanceMessage }
</p>
</div>
</div>
</div>
}
<!-- System Overview -->
<div class="mb-8">
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
System Overview
</h2>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-database text-blue-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Database Size
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.DatabaseSize }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-history text-green-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Job History Records
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.JobHistoryCount) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-clock text-purple-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
System Uptime
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ data.SystemUptime }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
Active Jobs
</dt>
<dd>
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.ActiveJobs) }
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Admin Tools Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Backup & Restore -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-save mr-2 text-primary-500"></i>
Backup & Restore
</h3>
</div>
<div class="card-body space-y-4">
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
if data.LastBackupTime != nil {
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
} else {
<span>Last backup: Never</span>
}
</div>
<button id="backup-form"
hx-post="/admin/backup-database"
hx-swap="none"
hx-indicator="#backup-indicator"
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
class="btn-primary w-full flex items-center justify-center">
<i class="fas fa-database mr-2"></i>
<span>Backup Database</span>
<div id="backup-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<!-- Hidden refresh trigger -->
<button id="refresh-backups-trigger"
class="hidden"
hx-get="/admin/refresh-backups"
hx-target="#backups-container"></button>
<form id="export-configs-form" action="/admin/export-configs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Configurations</span>
<div id="export-configs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-file-export mr-2"></i>
<span>Export All Jobs</span>
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
<div class="flex items-center space-x-2">
<div class="flex-grow">
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Select Backup File
</label>
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-medium
file:bg-primary-50 file:text-primary-700
dark:file:bg-primary-900 dark:file:text-primary-300
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
focus:outline-none" required />
</div>
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
<div class="flex items-center justify-center">
<i class="fas fa-upload mr-1"></i>
<span>Restore</span>
<div id="restore-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</div>
</button>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
<i class="fas fa-exclamation-triangle mr-1"></i>
Warning: This will replace your current database. Make sure to backup first!
</p>
</form>
</div>
</div>
</div>
<!-- Maintenance Tools -->
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-wrench mr-2 text-primary-500"></i>
Maintenance Tools
</h3>
</div>
<div class="card-body space-y-4">
<!-- Clear Job History Button and Dialog -->
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
<i class="fas fa-trash-alt mr-2"></i>
<span>Clear Job History</span>
<div id="clear-history-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
</form>
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
<i class="fas fa-compress-alt mr-2"></i>
<span>Optimize Database</span>
<div id="vacuum-indicator" class="htmx-indicator ml-2">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
</button>
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
Runs VACUUM to optimize the database and reclaim unused space.
</p>
</form>
</div>
</div>
</div>
</div>
<!-- Available Backups -->
<div id="backups-container" class="mt-8">
@BackupsList(data)
</div>
<!-- System Information -->
<div class="mt-8">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-info-circle mr-2 text-primary-500"></i>
System Information
</h3>
</div>
<div class="card-body">
<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">Database 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 overflow-auto">
{ data.DatabasePath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</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 overflow-auto">
{ data.BackupPath }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalUsers) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalConfigs) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
{ fmt.Sprint(data.TotalJobs) }
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
}
}
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
templ BackupsList(data AdminToolsData) {
if len(data.BackupFiles) > 0 {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead>
<tr class="bg-secondary-100 dark:bg-secondary-800">
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
for _, backup := range data.BackupFiles {
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
<td class="px-4 py-2 text-right">
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
title="Download backup">
<i class="fas fa-download"></i>
</a>
<!-- Restore Dialog -->
@BackupActionDialog(
fmt.Sprintf("restore-dialog-%s", backup.Name),
"RESTORE BACKUP",
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
"btn-warning",
"Restore",
"restore",
backup.Name,
)
<button
type="button"
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
title="Restore this backup"
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
<i class="fas fa-upload"></i>
</button>
<!-- Delete Dialog -->
@BackupActionDialog(
fmt.Sprintf("delete-dialog-%s", backup.Name),
"DELETE BACKUP",
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
"btn-danger",
"Delete",
"delete",
backup.Name,
)
<button
type="button"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Delete backup"
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
} else {
<div class="card">
<div class="card-header flex justify-between items-center">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-history mr-2 text-primary-500"></i>
Available Backups
</h3>
<button
hx-get="/admin/refresh-backups"
hx-target="#backups-container"
hx-indicator="#refresh-backups-indicator"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
title="Refresh backups list">
<i class="fas fa-sync-alt"></i>
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
<i class="fas fa-circle-notch fa-spin"></i>
</span>
</button>
</div>
<div class="card-body">
<div class="text-center py-6">
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
</div>
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Create a backup using the "Backup Database" button.
</p>
</div>
</div>
</div>
}
}
+655
View File
@@ -0,0 +1,655 @@
package components
import (
"fmt"
"encoding/json"
"context"
"github.com/starfleetcptn/gomft/internal/db"
)
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
@LayoutWithContext(getPageTitle(isNew), ctx) {
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
if isNew {
<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>
New Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
} else {
<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>
Edit Authentication Provider
</h1>
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
}
</div>
<div>
<a href="/admin/settings/auth-providers" 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-4 py-2 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-arrow-left w-4 h-4 mr-2"></i> Back to Providers
</a>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
if isNew {
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
@formContent(provider, isNew)
</form>
} else {
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
<input type="hidden" name="_method" value="PUT" />
@formContent(provider, isNew)
</form>
}
</div>
<!-- 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">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key 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">Make sure to enter the correct callback URL in your external provider's configuration.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
</div>
</div>
</div>
</div>
</div>
<script>
function updateFormFields() {
// Hide all provider-specific sections first
document.querySelectorAll('.provider-specific-section').forEach(section => {
section.style.display = 'none';
});
// Show the relevant section based on selected provider type
const providerType = document.getElementById('type').value;
if (providerType === 'authentik') {
document.getElementById('authentikSection').style.display = 'block';
} else if (providerType === 'oidc') {
document.getElementById('oidcSection').style.display = 'block';
} else if (providerType === 'saml') {
document.getElementById('samlSection').style.display = 'block';
}
}
function testProviderConnection() {
const providerId = window.location.pathname.split('/').slice(-2)[0];
const testBtn = event.target;
const originalText = testBtn.innerHTML;
testBtn.disabled = true;
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
testBtn.disabled = false;
testBtn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
testBtn.disabled = false;
testBtn.innerHTML = originalText;
});
}
// Handle icon URL preview
function updateIconPreview() {
const iconUrl = document.getElementById('icon_url').value;
const previewImg = document.getElementById('preview-img');
const previewPlaceholder = document.getElementById('preview-placeholder');
if (iconUrl && iconUrl.trim() !== '') {
// Create image element if it doesn't exist
if (!previewImg) {
const img = document.createElement('img');
img.id = 'preview-img';
img.className = 'max-w-full max-h-full';
img.onerror = function() {
// If image fails to load, show placeholder
this.style.display = 'none';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
} else {
const icon = document.createElement('i');
icon.id = 'preview-placeholder';
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
document.getElementById('icon-preview').appendChild(icon);
}
};
img.onload = function() {
// If image loads successfully, hide placeholder
this.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
};
document.getElementById('icon-preview').appendChild(img);
}
// Update image source
if (previewImg) {
previewImg.src = iconUrl;
previewImg.style.display = 'block';
if (previewPlaceholder) {
previewPlaceholder.style.display = 'none';
}
}
} else {
// If no URL, show placeholder
if (previewImg) {
previewImg.style.display = 'none';
}
if (previewPlaceholder) {
previewPlaceholder.style.display = 'block';
}
}
}
// Initialize the form on page load
document.addEventListener('DOMContentLoaded', function() {
updateFormFields();
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
}
// Add event listener for 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('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Add event listener for icon URL changes
const iconUrlInput = document.getElementById('icon_url');
if (iconUrlInput) {
iconUrlInput.addEventListener('input', updateIconPreview);
// Initial preview
updateIconPreview();
}
});
</script>
}
}
func getPageTitle(isNew bool) string {
if isNew {
return "New Authentication Provider"
}
return "Edit Authentication Provider"
}
templ formContent(provider *db.AuthProvider, isNew bool) {
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></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 dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g. Authentik SSO"
required
value={ getValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
</div>
<!-- Provider Type -->
<div>
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
<select id="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" required onchange="updateFormFields()">
<option value="">Select a provider type</option>
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
<option value="authentik" selected>Authentik</option>
} else {
<option value="authentik">Authentik</option>
}
if provider != nil && provider.Type == db.ProviderTypeOIDC {
<option value="oidc" selected>OpenID Connect (OIDC)</option>
} else {
<option value="oidc">OpenID Connect (OIDC)</option>
}
if provider != nil && provider.Type == db.ProviderTypeSAML {
<option value="saml" selected>SAML 2.0</option>
} else {
<option value="saml">SAML 2.0</option>
}
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
<option value="oauth2" selected>OAuth 2.0</option>
} else {
<option value="oauth2">OAuth 2.0</option>
}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
</div>
<!-- Enabled -->
<div>
<div class="flex items-center">
if provider == nil || provider.Enabled {
<input
type="checkbox"
id="enabled"
name="enabled"
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"
checked
/>
} else {
<input
type="checkbox"
id="enabled"
name="enabled"
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="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea
id="description"
name="description"
class="bg-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"
rows="3"
placeholder="Optional description of this provider"
>
if provider != nil {
{ provider.Description }
}
</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
</div>
</div>
<!-- Icon URL -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
<input
type="url"
id="icon_url"
name="icon_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://example.com/icon.svg"
value={ provider.IconURL }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
<div class="flex items-center">
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
if provider != nil && provider.IconURL != "" {
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
} else {
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
}
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Provider URL -->
<div>
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
<input
type="url"
id="provider_url"
name="provider_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://authentik.example.com"
required
value={ getValue(provider, "provider_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
</div>
<!-- Client ID -->
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
<input
type="text"
id="client_id"
name="client_id"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
required
value={ getValue(provider, "client_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
</div>
<!-- Client Secret -->
<div>
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Secret
if !isNew {
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
} else {
<span class="text-red-600">*</span>
}
</label>
if isNew {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-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"
required
/>
} else {
<input
type="password"
id="client_secret"
name="client_secret"
class="bg-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"
/>
}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
</div>
<!-- Redirect URL -->
<div>
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
<input
type="url"
id="redirect_url"
name="redirect_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://your-app.example.com/auth/callback"
required
value={ getRedirectURL(provider) }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
</div>
<!-- Scopes -->
<div>
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
<input
type="text"
id="scopes"
name="scopes"
class="bg-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="openid profile email"
value={ getValue(provider, "scopes") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Authentik Tenant ID -->
<div>
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
<input
type="text"
id="authentik_tenant"
name="authentik_tenant"
class="bg-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="default"
value={ getConfigValue(provider, "tenant_id") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- OIDC Discovery URL -->
<div>
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
<input
type="url"
id="oidc_discovery_url"
name="oidc_discovery_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://provider/.well-known/openid-configuration"
value={ getConfigValue(provider, "discovery_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
<div class="grid grid-cols-1 gap-6">
<!-- SAML Metadata URL -->
<div>
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
<input
type="url"
id="saml_metadata_url"
name="saml_metadata_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://provider/metadata.xml"
value={ getConfigValue(provider, "metadata_url") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Username Attribute -->
<div>
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
<input
type="text"
id="attr_username"
name="attr_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="preferred_username"
value={ getAttributeValue(provider, "username") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
</div>
<!-- Email Attribute -->
<div>
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
<input
type="text"
id="attr_email"
name="attr_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="email"
value={ getAttributeValue(provider, "email") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
</div>
<!-- Display Name Attribute -->
<div>
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
<input
type="text"
id="attr_name"
name="attr_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="name"
value={ getAttributeValue(provider, "name") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
</div>
<!-- Groups Attribute -->
<div>
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
<input
type="text"
id="attr_groups"
name="attr_groups"
class="bg-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="groups"
value={ getAttributeValue(provider, "groups") }
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
</div>
</div>
</div>
<div class="flex justify-end gap-4 mt-6">
<a href="/admin/settings/auth-providers" 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-5 py-2.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">
Cancel
</a>
if !isNew {
<button type="button" 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-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
Test Connection
</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 inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if isNew {
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create Provider
} else {
<i class="fas fa-save w-4 h-4 mr-2"></i>
Save Changes
}
</button>
</div>
}
func getValue(provider *db.AuthProvider, field string) string {
if provider == nil {
return ""
}
switch field {
case "name":
return provider.Name
case "provider_url":
return provider.ProviderURL
case "client_id":
return provider.ClientID
case "redirect_url":
return provider.RedirectURL
case "scopes":
return provider.Scopes
case "icon_url":
return provider.IconURL
default:
return ""
}
}
func getRedirectURL(provider *db.AuthProvider) string {
if provider == nil || provider.RedirectURL == "" {
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
}
return provider.RedirectURL
}
func getConfigValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.Config == "" {
return ""
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
return ""
}
if value, ok := config[key]; ok {
if strValue, ok := value.(string); ok {
return strValue
}
}
return ""
}
func getAttributeValue(provider *db.AuthProvider, key string) string {
if provider == nil || provider.AttributeMapping == "" {
return ""
}
var mapping map[string]string
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
return ""
}
if value, ok := mapping[key]; ok {
return value
}
return ""
}
+380
View File
@@ -0,0 +1,380 @@
package components
import (
"context"
"fmt"
"time"
"github.com/starfleetcptn/gomft/internal/db"
)
// formatTime formats a time.Time value as a human-readable string
func formatTime(t time.Time) string {
return t.Format("Jan 02, 2006 15:04")
}
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
@LayoutWithContext("Authentication Providers", ctx) {
<!-- Toast container for notifications -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
<div class="p-4 pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<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>
Authentication Providers
</h1>
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
</div>
<div>
<a href="/admin/settings/auth-providers/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 Provider
</a>
</div>
</div>
<div class="mt-6">
if len(providers) > 0 {
<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 rtl:text-right">
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3">Name</th>
<th scope="col" class="px-6 py-3">Icon</th>
<th scope="col" class="px-6 py-3">Type</th>
<th scope="col" class="px-6 py-3">Status</th>
<th scope="col" class="px-6 py-3">Provider URL</th>
<th scope="col" class="px-6 py-3">Last Used</th>
<th scope="col" class="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody>
for _, provider := range providers {
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
{ provider.Name }
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-center">
if provider.IconURL != "" {
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
} else {
if provider.Type == db.ProviderTypeAuthentik {
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
} else if provider.Type == db.ProviderTypeOIDC {
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
} else if provider.Type == db.ProviderTypeSAML {
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
} else if provider.Type == db.ProviderTypeOAuth2 {
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
} else {
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
}
}
</div>
</td>
<td class="px-6 py-4 uppercase">
{ string(provider.Type) }
</td>
<td class="px-6 py-4">
if provider.Enabled {
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
} else {
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
}
</td>
<td class="px-6 py-4 max-w-[200px] truncate">
{ provider.ProviderURL }
</td>
<td class="px-6 py-4">
if provider.LastUsed.Valid {
{ formatTime(provider.LastUsed.Time) }
} else {
<span class="text-gray-400 dark:text-gray-500">Never</span>
}
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
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"
title="Edit">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<button
type="button"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
class="delete-provider-btn 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"
title="Delete">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
<button
data-provider-id={ fmt.Sprint(provider.ID) }
onclick="testProvider(this)"
class="text-blue-700 bg-blue-100 hover:bg-blue-200 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 dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
title="Test Connection">
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
Test
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} else {
<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-user-shield 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 Authentication Providers</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
You haven't set up any external authentication providers yet.
</p>
<a href="/admin/settings/auth-providers/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>
Add First Provider
</a>
</div>
}
</div>
<!-- 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">Authentication providers allow users to sign in using external identity providers.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-key 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">Make sure to configure callback URLs in your provider's settings.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Provider Modal using Flowbite style -->
<div id="deleteProviderModal" 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-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 the provider <strong id="providerNameConfirm"></strong>?</h3>
<button
type="button"
id="confirmDeleteBtn"
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">
Delete
</button>
<button type="button" onclick="closeDeleteModal()" 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>
</div>
</div>
</div>
</div>
<script>
// Toast notification function
function showToast(type, message) {
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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
function confirmDeleteProvider(providerId, providerName) {
document.getElementById('providerNameConfirm').textContent = providerName;
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
// Show the modal
document.getElementById('deleteProviderModal').classList.remove('hidden');
document.getElementById('deleteProviderModal').classList.add('flex');
}
function closeDeleteModal() {
document.getElementById('deleteProviderModal').classList.add('hidden');
document.getElementById('deleteProviderModal').classList.remove('flex');
}
function deleteProvider(providerId) {
fetch(`/admin/settings/auth-providers/${providerId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else {
showToast('success', 'Authentication provider deleted successfully');
setTimeout(() => {
window.location.reload();
}, 1000);
}
closeDeleteModal();
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Failed to delete authentication provider');
closeDeleteModal();
});
}
function testProvider(btn) {
const providerId = btn.getAttribute('data-provider-id');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.error) {
showToast('error', data.error);
} else if (data.success) {
showToast('success', data.message || 'Connection test successful');
} else {
showToast('error', 'Connection test failed');
}
btn.disabled = false;
btn.innerHTML = originalText;
})
.catch(error => {
console.error('Error:', error);
showToast('error', 'Connection test failed');
btn.disabled = false;
btn.innerHTML = originalText;
});
}
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
}
// Add event listener for 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('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Set up event listeners for delete buttons
document.querySelectorAll('.delete-provider-btn').forEach(button => {
button.addEventListener('click', function() {
const providerId = this.getAttribute('data-provider-id');
const providerName = this.getAttribute('data-provider-name');
confirmDeleteProvider(providerId, providerName);
});
});
});
</script>
}
}
+52
View File
@@ -0,0 +1,52 @@
package components
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
// getProviderIcon returns the appropriate icon for a provider
func getProviderIcon(provider db.AuthProvider) templ.Component {
// If provider has a custom icon URL, use it
if provider.IconURL != "" {
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
}
// Otherwise fall back to default icons based on type
switch provider.Type {
case db.ProviderTypeAuthentik:
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
case db.ProviderTypeOIDC:
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
case db.ProviderTypeSAML:
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
case db.ProviderTypeOAuth2:
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
default:
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
}
}
templ AuthProviderButtons(providers []db.AuthProvider) {
if len(providers) == 0 {
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
No external authentication providers available
</div>
} else {
<div class="space-y-2 w-full">
for _, provider := range providers {
if provider.Enabled {
<a
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
@getProviderIcon(provider)
</span>
<span>{ provider.Name }</span>
</a>
}
}
</div>
}
}
+427 -1812
View File
File diff suppressed because it is too large Load Diff
+329 -106
View File
@@ -6,55 +6,68 @@ 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 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">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<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">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-circle text-yellow-400 text-3xl 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" onclick={ closeModal(id) } 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>
</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>
}
script hideConfigDialog(id string) {
document.getElementById(id).classList.add("hidden");
script closeModal(id string) {
const modal = document.getElementById(id);
const backdrop = document.getElementById(id + '-backdrop');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (backdrop) {
backdrop.remove();
}
document.body.style.overflow = '';
}
script showConfigDialog(id string) {
document.getElementById(id).classList.remove("hidden");
script showModal(id string) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
}
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 = {
@@ -68,44 +81,123 @@ script triggerConfigDelete(dialogId string, configID uint, configName string) {
type ConfigsData struct {
Configs []db.TransferConfig
Error string
ErrorDetails string
Status string
}
templ Configs(ctx context.Context, data ConfigsData) {
@LayoutWithContext("Transfer Configurations", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Debug notification system
console.log("Configs template loaded, setting up notification system");
// Create a global notyf instance if it doesn't exist yet
if (!window.notyf) {
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
// 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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
console.log("Notyf initialized:", window.notyf);
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Show status messages based on URL parameters
document.addEventListener('DOMContentLoaded', function() {
// Check for error message
const urlParams = new URLSearchParams(window.location.search);
const errorMsg = urlParams.get('error');
const errorDetails = urlParams.get('details');
const status = urlParams.get('status');
if (errorMsg) {
let message = errorMsg;
if (errorDetails) {
message += ": " + errorDetails;
}
showToast(message, 'error');
}
if (status === 'gdrive_auth_success') {
showToast("Google Drive authentication completed successfully", 'success');
}
// 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');
});
});
});
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
@@ -119,6 +211,18 @@ templ Configs(ctx context.Context, data ConfigsData) {
// This is definitely a delete request - store this information
window.isConfigDeleteRequest = true;
}
// Check if this is a duplication request
if (path && method === 'POST' && path.match(/^\/configs\/\d+\/duplicate$/)) {
window.isConfigDuplicateRequest = true;
// Store the config ID for reference
const configId = path.match(/^\/configs\/(\d+)\/duplicate$/)[1];
const configButton = document.querySelector(`button[hx-post="/configs/${configId}/duplicate"]`);
if (configButton) {
window.duplicatingConfigName = configButton.closest('li').querySelector('.text-blue-600').textContent.trim();
}
}
});
// Track HTMX after-request events for config deletion
@@ -151,13 +255,29 @@ templ Configs(ctx context.Context, data ConfigsData) {
configName = window.lastDeletedConfig.name;
}
window.notyf.success(`Configuration "${configName}" deleted successfully`);
showToast(`Configuration "${configName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
// Check if this is a duplication request
const isDuplicateRequest = window.isConfigDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
// If this is a successful duplication request, show notification
if (isDuplicateRequest && event.detail.successful) {
const configName = window.duplicatingConfigName || "configuration";
showToast(`Configuration "${configName}" duplicated successfully`, 'success');
// Clear flags
window.isConfigDuplicateRequest = false;
window.duplicatingConfigName = null;
}
});
// Track HTMX error events for config deletion
@@ -194,68 +314,129 @@ templ Configs(ctx context.Context, data ConfigsData) {
errorMsg = `Error: ${error.error}`;
}
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingConfig = false;
window.isConfigDeleteRequest = false;
window.lastDeletedConfig = null;
}
// Check if this is a duplication request
const isDuplicateRequest = window.isConfigDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
if (isDuplicateRequest) {
const configName = window.duplicatingConfigName || "configuration";
let errorMsg = `Failed to duplicate configuration "${configName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = `Error: ${error.error}`;
} catch (e) {
// If not JSON, use the response text directly
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
}
}
}
showToast(errorMsg, 'error');
// Clear flags
window.isConfigDuplicateRequest = false;
window.duplicatingConfigName = null;
}
});
</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 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 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>
}
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
<i class="fas fa-edit mr-1"></i>
<!-- 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="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="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>
<!-- Duplicate config button -->
<button
type="button"
hx-post={ fmt.Sprintf("/configs/%d/duplicate", config.ID) }
hx-target="body"
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
Duplicate
</button>
<!-- Add delete dialog for each configuration -->
@ConfigDialog(
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,
@@ -263,26 +444,26 @@ 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>
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
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>
@@ -297,14 +478,56 @@ 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>
<!-- 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 class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</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>
}
}
+186 -93
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"os/exec"
"strconv"
"strings"
)
type DashboardData struct {
@@ -12,19 +14,45 @@ type DashboardData struct {
ActiveTransfers int
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>
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Dashboard
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<span id="current-date" class="font-medium"></span>
<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>
@@ -32,154 +60,199 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</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 class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Active Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<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 class="flex items-center">
<div class="flex-shrink-0">
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
</div>
<div class="ml-3 flex items-baseline text-sm font-medium">
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
</div>
</div>
</div>
<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>
<!-- Completed Today -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<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.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>
<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>
<!-- Failed Transfers -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</div>
<div>
<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="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>
<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>
<!-- Main Content Area -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Recent Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Recent Jobs
</h3>
</div>
<div class="card-body">
<div class="p-4">
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>
<!-- 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>
<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>
<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 role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
<ul class="divide-y divide-gray-200 dark:divide-gray-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">
<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="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 class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else if job.Status == "failed" {
<span class="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 class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
</span>
} else {
<span class="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 class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
</span>
}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
{ job.Job.Config.Name }
<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 text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
</p>
</div>
</div>
<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>
<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="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>
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list w-4 h-4 mr-2"></i>
View all jobs
</a>
</div>
}
</div>
</div>
<div 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 Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
Quick Actions
</h3>
</div>
<div class="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>
<div class="p-4 space-y-4">
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-5 h-5 mr-2"></i>
Create New Config
</a>
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
<i class="fas fa-play-circle mr-2"></i>
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
Create New Job
</a>
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
<i class="fas fa-history mr-2"></i>
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-history w-5 h-5 mr-2"></i>
View Transfer History
</a>
<!-- System Status Card -->
<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 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>
</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>
</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>
<!-- System Status Card with Flowbite design -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
</div>
<div class="px-4 py-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Online
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Running
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
Connected
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
if data.RcloneVersion != "" {
{ data.RcloneVersion }
} else {
Unknown
}
</span>
</div>
</div>
</div>
</div>
@@ -187,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
+188
View File
@@ -0,0 +1,188 @@
package components
import (
"github.com/starfleetcptn/gomft/internal/db"
"time"
"fmt"
)
type NotificationsData struct {
Notifications []db.UserNotification
UnreadCount int64
// Pagination fields
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
// FormatNotificationTime formats a notification time in a user-friendly way
func FormatNotificationTime(t time.Time) string {
now := time.Now()
diff := now.Sub(t)
if diff < time.Minute {
return "just now"
} else if diff < time.Hour {
minutes := int(diff.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", minutes)
} else if diff < 24*time.Hour {
hours := int(diff.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
} else if diff < 48*time.Hour {
return "yesterday"
} else {
days := int(diff.Hours() / 24)
if days < 7 {
return fmt.Sprintf("%d days ago", days)
} else {
return t.Format("Jan 2")
}
}
}
// GetNotificationIcon returns the appropriate icon class based on notification type
func GetNotificationIcon(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "fas fa-play text-blue-600 dark:text-blue-300"
case db.NotificationJobComplete:
return "fas fa-check-circle text-green-600 dark:text-green-300"
case db.NotificationJobFail:
return "fas fa-exclamation-circle text-red-600 dark:text-red-300"
case db.NotificationConfigUpdate:
return "fas fa-cog text-blue-600 dark:text-blue-300"
case db.NotificationSystemAlert:
return "fas fa-bell text-yellow-600 dark:text-yellow-300"
default:
return "fas fa-info-circle text-blue-600 dark:text-blue-300"
}
}
// GetNotificationBgColor returns the appropriate background color class based on notification type
func GetNotificationBgColor(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "bg-blue-100 dark:bg-blue-900"
case db.NotificationJobComplete:
return "bg-green-100 dark:bg-green-900"
case db.NotificationJobFail:
return "bg-red-100 dark:bg-red-900"
case db.NotificationConfigUpdate:
return "bg-blue-100 dark:bg-blue-900"
case db.NotificationSystemAlert:
return "bg-yellow-100 dark:bg-yellow-900"
default:
return "bg-blue-100 dark:bg-blue-900"
}
}
// GetNotificationBadgeColor returns the appropriate badge color class based on notification type
func GetNotificationBadgeColor(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "bg-primary-700"
case db.NotificationJobComplete:
return "bg-green-600"
case db.NotificationJobFail:
return "bg-red-600"
case db.NotificationConfigUpdate:
return "bg-blue-500"
case db.NotificationSystemAlert:
return "bg-yellow-500"
default:
return "bg-primary-700"
}
}
// GetNotificationBadgeIcon returns the appropriate badge icon class based on notification type
func GetNotificationBadgeIcon(notificationType db.NotificationType) string {
switch notificationType {
case db.NotificationJobStart:
return "fas fa-play"
case db.NotificationJobComplete:
return "fas fa-check"
case db.NotificationJobFail:
return "fas fa-times"
case db.NotificationConfigUpdate:
return "fas fa-wrench"
case db.NotificationSystemAlert:
return "fas fa-exclamation"
default:
return "fas fa-info"
}
}
templ NotificationDropdown(data NotificationsData) {
<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>
if len(data.Notifications) == 0 {
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-bell-slash text-2xl mb-2"></i>
<p>No notifications</p>
</div>
} else {
for _, notification := range data.Notifications {
<a
href={ templ.SafeURL(notification.Link) }
class={ "flex py-3 px-4 border-b hover:bg-gray-100 dark:hover:bg-gray-600 dark:border-gray-600", templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
<div class="flex-shrink-0">
<div class={ "w-11 h-11 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i>
</div>
// <div class={ "flex absolute justify-center items-center ml-6 -mt-5 w-5 h-5 rounded-full border border-white dark:border-gray-700", GetNotificationBadgeColor(notification.Type) }>
// <i class={ "text-white text-xs", GetNotificationBadgeIcon(notification.Type) }></i>
// </div>
</div>
<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">{ notification.Title }:</span> { notification.Message }
</div>
<div class="text-xs font-medium text-primary-600 dark:text-primary-500">
{ FormatNotificationTime(notification.CreatedAt) }
</div>
</div>
</a>
}
}
</div>
<div class="flex">
<a href="/notifications" class="block w-1/2 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:bg-gray-500">
<div class="inline-flex items-center">
<i class="far fa-eye mr-2"></i>
View all
</div>
</a>
<a href="/notifications/mark-all-read"
hx-post="/notifications/mark-all-read"
hx-swap="none"
hx-target="#notification-count"
class="block w-1/2 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:bg-gray-500 border-l border-gray-200 dark:border-gray-700">
<div class="inline-flex items-center">
<i class="fas fa-check-double mr-2"></i>
Mark all read
</div>
</a>
</div>
}
templ NotificationCount(count int64) {
if count > 0 {
<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" id="notification-count">
if count > 99 {
99+
} else {
{ fmt.Sprintf("%d", count) }
}
</div>
}
}
@@ -0,0 +1,315 @@
package components
import (
"context"
"fmt"
"strconv"
)
// Note: To implement pagination, update the NotificationsData struct in notifications.templ
// to include these fields:
// CurrentPage int
// TotalPages int
// TotalCount int
// PerPage int
templ NotificationsPage(ctx context.Context, data NotificationsData) {
@LayoutWithContext("Notifications", ctx) {
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notifications
</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.TotalCount) } notifications</span>
</div>
</div>
<!-- Controls and Filters -->
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
id="perPage"
name="perPage"
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"
hx-get={ fmt.Sprintf("/notifications?page=1&perPage=%s", "${this.value}") }
hx-target="body"
hx-swap="innerHTML"
hx-trigger="change"
>
<option value="10" selected?={ data.PerPage == 10 }>10</option>
<option value="25" selected?={ data.PerPage == 25 }>25</option>
<option value="50" selected?={ data.PerPage == 50 }>50</option>
<option value="100" selected?={ data.PerPage == 100 }>100</option>
</select>
entries
</label>
</div>
<a
href="/notifications/mark-all-read"
hx-post="/notifications/mark-all-read"
hx-swap="none"
hx-target="#notification-bell"
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-check-double w-4 h-4 mr-2"></i>
Mark all as read
</a>
</div>
</div>
<!-- Main Content -->
if len(data.Notifications) == 0 {
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<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-bell-slash text-gray-400 dark:text-gray-500 text-3xl"></i>
</span>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No notifications</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">You don't have any notifications yet.</p>
</div>
} else {
<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 _, notification := range data.Notifications {
<li id={ "notification-" + fmt.Sprintf("%d", notification.ID) }
class={ templ.KV("bg-blue-50 dark:bg-blue-900/20", !notification.IsRead) }>
<div class="block transition-colors px-4 py-4 sm:px-6">
<div class="flex items-start">
<div class="flex-shrink-0 mr-4">
<div class={ "w-12 h-12 rounded-full flex items-center justify-center", GetNotificationBgColor(notification.Type) }>
<i class={ GetNotificationIcon(notification.Type) }></i>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<div>
<h3 class="text-base font-medium text-gray-900 dark:text-white">{ notification.Title }</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{ notification.Message }</p>
</div>
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
<span>{ FormatNotificationTime(notification.CreatedAt) }</span>
if !notification.IsRead {
<button
hx-post={ "/notifications/" + fmt.Sprintf("%d", notification.ID) + "/read" }
hx-target={ "#notification-" + fmt.Sprintf("%d", notification.ID) }
hx-swap="outerHTML"
class="ml-4 text-blue-700 bg-blue-100 hover:bg-blue-200 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 dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800"
>
<i class="fas fa-check w-3.5 h-3.5 mr-1.5"></i>
Mark as read
</button>
}
</div>
</div>
<div class="mt-2">
<a
href={ templ.SafeURL(notification.Link) }
class="text-blue-700 bg-blue-100 hover:bg-blue-200 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 dark:text-blue-400 dark:bg-blue-900/30 dark:hover:bg-blue-900/40 dark:focus:ring-blue-800">
<i class="fas fa-eye w-3.5 h-3.5 mr-1.5"></i>
View details
</a>
</div>
</div>
</div>
</div>
</li>
}
</ul>
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 px-4 border-t border-gray-200 dark:border-gray-700">
<!-- Mobile pagination -->
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
if data.CurrentPage > 1 {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage-1) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
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"
>
<i class="fas fa-chevron-left mr-1"></i> Previous
</a>
} else {
<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>
}
if data.CurrentPage < data.TotalPages {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.CurrentPage+1) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
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"
>
Next <i class="fas fa-chevron-right ml-1"></i>
</a>
} else {
<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>
<!-- Desktop pagination -->
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PerPage + 1) }</span>
to
<span class="font-medium">{ fmt.Sprint(minInt((data.CurrentPage)*data.PerPage, data.TotalCount)) }</span>
of
<span class="font-medium">{ fmt.Sprint(data.TotalCount) }</span>
notifications
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<!-- First page -->
<a
href={ templ.SafeURL("/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=1&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
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", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
>
<span class="sr-only">First</span>
<i class="fas fa-angle-double-left text-xs"></i>
</a>
<!-- Previous page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(maxInt(1, data.CurrentPage-1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center 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", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == 1) }
>
<span class="sr-only">Previous</span>
<i class="fas fa-angle-left text-xs"></i>
</a>
<!-- Page numbers -->
@notificationPageNumbers(data.CurrentPage, data.TotalPages, data.PerPage)
<!-- Next page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(minInt(data.TotalPages, data.CurrentPage+1)) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
class={ "relative inline-flex items-center justify-center 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", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
>
<span class="sr-only">Next</span>
<i class="fas fa-angle-right text-xs"></i>
</a>
<!-- Last page -->
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(data.TotalPages) + "&perPage=" + strconv.Itoa(data.PerPage) }
hx-target="body"
hx-swap="innerHTML"
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", templ.KV("opacity-50 pointer-events-none", data.CurrentPage == data.TotalPages) }
>
<span class="sr-only">Last</span>
<i class="fas fa-angle-double-right text-xs"></i>
</a>
</nav>
</div>
</div>
</div>
}
</div>
}
<!-- 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">Notifications keep you updated on important system events and alerts.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-bell 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">Unread notifications are highlighted in blue. Mark them as read to clear the highlight.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-list-ol 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">Adjust how many notifications to display per page using the dropdown menu.</p>
</div>
</div>
</div>
</div>
}
}
// Helper function for pagination math
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// Helper function for pagination math
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// Add the pageNumbers template for consistent pagination layout
templ notificationPageNumbers(currentPage int, totalPages int, perPage int) {
// 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="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 dark:bg-blue-600">
{ fmt.Sprint(i) }
</span>
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
<a
href={ templ.SafeURL("/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage)) }
hx-get={ "/notifications?page=" + strconv.Itoa(i) + "&perPage=" + strconv.Itoa(perPage) }
hx-target="body"
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
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>
</a>
} else if i == currentPage-3 || i == currentPage+3 {
<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>
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package components
import (
"context"
"fmt"
)
// ErrorPage renders a generic error page with customizable title, message, and details
templ ErrorPage(ctx context.Context, code int, title, message, details string) {
@LayoutWithContext(fmt.Sprintf("Error %d - %s", code, title), ctx) {
<div class="min-h-screen flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-lg bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden">
<div class="px-6 py-8">
<div class="text-center">
if code == 404 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-blue-100 dark:bg-blue-900">
<svg class="h-14 w-14 text-blue-600 dark:text-blue-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
} else if code == 401 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-yellow-100 dark:bg-yellow-900">
<svg class="h-14 w-14 text-yellow-600 dark:text-yellow-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
} else if code == 500 {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-red-100 dark:bg-red-900">
<svg class="h-14 w-14 text-red-600 dark:text-red-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
} else {
<div class="mx-auto flex items-center justify-center h-24 w-24 rounded-full bg-gray-100 dark:bg-gray-700">
<svg class="h-14 w-14 text-gray-600 dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
}
<h1 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
{ fmt.Sprintf("%d - %s", code, title) }
</h1>
<p class="mt-3 text-lg text-gray-500 dark:text-gray-400">
{ message }
</p>
if details != "" {
<div class="mt-4 p-4 rounded-md bg-gray-50 dark:bg-gray-700 overflow-auto">
<p class="text-sm text-gray-600 dark:text-gray-300 font-mono">
{ details }
</p>
</div>
}
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-600 flex justify-between">
<a href="/dashboard" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
Go to Dashboard
</a>
<a href="/" class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">
Go to Home
</a>
</div>
</div>
</div>
}
}
// NotFoundError renders a 404 Not Found error page
templ NotFoundError(ctx context.Context) {
@ErrorPage(ctx, 404, "Page Not Found", "The page you're looking for doesn't exist or has been moved.", "")
}
// UnauthorizedError renders a 401 Unauthorized error page
templ UnauthorizedError(ctx context.Context) {
@ErrorPage(ctx, 401, "Unauthorized", "You don't have permission to access this page.", "Please log in or contact your administrator if you believe this is a mistake.")
}
// ServerError renders a 500 Internal Server Error page
templ ServerError(ctx context.Context, details string) {
@ErrorPage(ctx, 500, "Server Error", "Something went wrong on our end.", details)
}
// GenericError renders a generic error page
templ GenericError(ctx context.Context, title, message, details string) {
@ErrorPage(ctx, 0, title, message, details)
}
File diff suppressed because it is too large Load Diff
+183 -203
View File
@@ -6,102 +6,91 @@ import (
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
@LayoutWithContext("Forgot Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
if successMessage != "" {
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span class="block sm:inline">{ successMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
We'll send a password reset link to this email
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
if successMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
<i class="fas fa-check-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/forgot-password"
x-data="{
email: '',
loading: false,
validate() {
return this.email && this.email.includes('@');
}
}">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="you@example.com"/>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
We'll send a password reset link to this email
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
// Reset password page for when users click the link from their email
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
@LayoutWithContext("Reset Password", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
<div class="max-w-md w-full">
<div class="card overflow-hidden shadow-lg">
<div class="p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8">
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
if errorMessage != "" {
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline">{ errorMessage }</span>
</div>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center py-3"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</div>
</form>
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
</div>
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</p>
if errorMessage != "" {
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
</div>
}
<form
class="space-y-6"
method="POST"
action="/reset-password"
x-data="{
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.password &&
this.password.length >= 8 &&
this.password === this.confirmPassword;
}
}">
<input type="hidden" name="token" value={token}/>
<div>
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Minimum 8 characters
</p>
</div>
<div>
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
</div>
<input
id="confirm-password"
name="confirm-password"
type="password"
required
x-model="confirmPassword"
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
placeholder="••••••••"/>
</div>
<p class="mt-1 text-sm"
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
<span x-show="!confirmPassword || password === confirmPassword">
Passwords must match
</span>
<span x-show="confirmPassword && password !== confirmPassword">
<i class="fas fa-exclamation-triangle mr-1"></i>
Passwords do not match
</span>
</p>
</div>
<button
type="submit"
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
x-bind:disabled="!validate()"
@click="loading = true">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-check-circle mr-2"></i>
Reset Password
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<i class="fas fa-arrow-left mr-2"></i>
Back to login
</a>
</div>
</div>
<!-- Security Notice -->
<div class="mt-8 text-center">
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-center">
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
<span>Secure, encrypted connection</span>
</div>
+194 -123
View File
@@ -13,6 +13,7 @@ type HistoryData struct {
SearchTerm string
PageSize int
Total int
Configs map[uint]db.TransferConfig // Map of config IDs to configs for quick lookup
}
// min returns the smaller of x or y
@@ -23,69 +24,113 @@ func min(x, y int) int {
return y
}
// getConfigNameForHistory returns the appropriate name for the config used in a job history entry
func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.TransferConfig) string {
// If ConfigID is set in the history record, use that to get the config name
if history.ConfigID > 0 {
if config, exists := configs[history.ConfigID]; exists {
return config.Name
}
}
// Fallback to the Job's default Config if it exists
if history.Job.Config.ID > 0 {
return history.Job.Config.Name
}
// If we can't determine the config name, show a default with the job name
if history.Job.Name != "" {
return fmt.Sprintf("%s (unknown config)", history.Job.Name)
}
return "Unknown Configuration"
}
// 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">{ history.Job.Config.Name }</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">
<i class="fas fa-check mr-1"></i> Completed
<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">
<i class="fas fa-times mr-1"></i> Failed
<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">
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
<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>
@@ -93,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>
@@ -108,22 +153,26 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
<!-- Pagination -->
if data.TotalPages > 1 {
<div class="mt-6 flex items-center justify-between">
<div class="flex-1 flex justify-between sm:hidden">
<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="relative 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 transition-colors">
<i class="fas fa-chevron-left mr-1"></i> Previous
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
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>
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 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>
}
@@ -134,21 +183,26 @@ 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="ml-3 relative 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 transition-colors">
Next <i class="fas fa-chevron-right ml-1"></i>
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
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>
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
<i class="fas fa-spinner fa-spin text-xs"></i>
</span>
</button>
} else {
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 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>
<!-- 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
@@ -159,24 +213,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<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="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></i>
<span id="prev-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></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="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left h-5 w-5"></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>
}
@@ -189,17 +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="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></i>
<span id="next-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></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="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right h-5 w-5"></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>
@@ -212,42 +264,45 @@ 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>
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
Transfer History
</h1>
<div class="text-sm text-secondary-500 dark:text-secondary-400">
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
</div>
</div>
<!-- Search and Pagination Controls -->
<div class="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 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="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"
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"
>
<span>Search</span>
<span id="search-indicator" class="htmx-indicator ml-1">
<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>
@@ -257,77 +312,93 @@ templ History(ctx context.Context, data HistoryData) {
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"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
>
<i class="fas fa-times mr-1"></i>
<i class="fas fa-times mr-2"></i>
Clear
<span id="clear-indicator" class="htmx-indicator ml-1">
<span id="clear-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</button>
}
</div>
<div class="ml-auto">
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
Show
<select
name="pageSize"
hx-get="/history"
hx-target="#history-content"
hx-include="[name='search']"
hx-indicator="#size-indicator"
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin"></i>
</span>
</label>
</div>
</form>
<div class="flex items-center">
<span class="text-sm text-secondary-700 dark:text-secondary-300">
Show
<select
name="pageSize"
hx-get="/history"
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"
>
<option value="10" selected?={ data.PageSize == 10 }>10</option>
<option value="25" selected?={ data.PageSize == 25 }>25</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
</select>
entries
<span id="size-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></i>
</span>
</span>
</div>
</div>
<div id="history-content" class="mt-6">
@HistoryContent(ctx, data)
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('history-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
// Show at most 5 page numbers with the current page in the middle when possible
{{startPage := max(1, currentPage-2)}}
{{endPage := min(totalPages, startPage+4)}}
// Adjust startPage if we're near the end
if endPage - startPage < 4 && startPage > 1 {
startPage = max(1, endPage-4)
}
for i := startPage; i <= endPage; i++ {
for i := 1; i <= totalPages; i++ {
if i == currentPage {
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
<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 {
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
<button
hx-get="/history"
hx-target="#history-content"
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
hx-indicator="#page-indicator"
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
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="page-indicator" class="htmx-indicator ml-1">
<i class="fas fa-spinner fa-spin"></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="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
...
</span>
}
}
}
+124 -99
View File
@@ -6,130 +6,155 @@ import (
templ Home(ctx context.Context) {
@LayoutWithContext("Home", ctx) {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center">
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
Welcome to GoMFT
</h1>
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
if isLoggedIn(ctx) {
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
<div class="flex justify-center">
<a href="/configs" class="btn-primary inline-flex items-center">
<span>View Configs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
<div class="flex justify-center">
<a href="/jobs" class="btn-primary inline-flex items-center">
<span>View Jobs</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
<div class="card hover:shadow-lg transition-shadow duration-300">
<div class="p-6">
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
<div class="flex justify-center">
<a href="/history" class="btn-primary inline-flex items-center">
<span>View History</span>
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
A modern managed file transfer solution
</p>
</div>
if isLoggedIn(ctx) {
<!-- Logged-in User View -->
<div class="space-y-8">
<!-- Feature Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Transfer Configs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
<a href="/configs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View Configs
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
<!-- Quick Access Section -->
<div class="mt-12">
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
Quick Access
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
<!-- Schedule Jobs Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
<a href="/jobs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View Jobs
<i class="fas fa-arrow-right ml-2"></i>
</a>
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
</a>
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
</a>
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
</div>
</div>
<!-- Track History Card -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
<div class="p-6 text-center">
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
<a href="/history" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
View History
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
} else {
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
<!-- 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>
}
}
+585 -251
View File
@@ -26,266 +26,600 @@ func getJobTitle(isNew bool) string {
return "Edit Job"
}
// configSelected checks if a config ID is selected for a job
func configSelected(job *db.Job, configID uint) bool {
if job.ConfigIDs != "" {
// If ConfigIDs is populated, only check against those IDs
for _, id := range job.GetConfigIDsList() {
if id == configID {
return true
}
}
return false
} else {
// If ConfigIDs is empty, fall back to checking the primary ConfigID
return job.ConfigID == configID
}
}
templ configSearchScript() {
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle search for new job form
const configSearch = document.getElementById('config-search');
if (configSearch) {
configSearch.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const configItems = document.querySelectorAll('#config-list .config-item');
configItems.forEach(item => {
const name = item.getAttribute('data-name').toLowerCase();
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
});
});
}
// Handle search for edit job form
const configSearchEdit = document.getElementById('config-search-edit');
if (configSearchEdit) {
configSearchEdit.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const configItems = document.querySelectorAll('#config-list-edit .config-item');
configItems.forEach(item => {
const name = item.getAttribute('data-name').toLowerCase();
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
});
});
}
// Handle job ordering
const setupJobOrdering = (configListId, selectedListId, formId, savedOrder) => {
const configList = document.getElementById(configListId);
const selectedList = document.getElementById(selectedListId);
const form = document.getElementById(formId);
if (!configList || !selectedList || !form) return;
// Get saved order if available
const orderedIds = savedOrder ? savedOrder.split(',').map(id => id.trim()) : [];
console.log('Initial saved order:', orderedIds);
// Initialize selected items from checked checkboxes
const updateSelectedItems = (initialLoad = false) => {
// Clear current list
selectedList.innerHTML = '';
// Get all checked checkboxes
const checkedItems = configList.querySelectorAll('input[type="checkbox"]:checked');
if (checkedItems.length === 0) {
selectedList.innerHTML = '<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">No configurations selected</div>';
return;
}
// Create a map of config items for easy access
const configItems = {};
checkedItems.forEach(checkbox => {
configItems[checkbox.value] = {
checkbox: checkbox,
configId: checkbox.value,
configName: checkbox.nextElementSibling.textContent.trim()
};
});
// If we have a saved order and this is the initial load, use that order
let itemsToShow = [];
if (initialLoad && orderedIds.length > 0) {
// First add items in the saved order
orderedIds.forEach(id => {
if (configItems[id]) {
itemsToShow.push(configItems[id]);
delete configItems[id]; // Remove from map to avoid duplicates
}
});
// Then add any remaining checked items not in the saved order
Object.values(configItems).forEach(item => {
itemsToShow.push(item);
});
} else {
// Just add all checked items in their current order
itemsToShow = Object.values(configItems);
}
// Add each item to the selected list
itemsToShow.forEach((item, index) => {
const configId = item.configId;
const configName = item.configName;
const listItem = document.createElement('div');
listItem.className = 'flex items-center justify-between p-2 mb-2 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg';
listItem.setAttribute('data-id', configId);
listItem.innerHTML = `
<div class="flex items-center">
<span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-primary-100 dark:bg-primary-900 mr-2 text-primary-700 dark:text-primary-300 text-sm">${index + 1}</span>
<span class="font-medium text-secondary-700 dark:text-secondary-300">${configName}</span>
</div>
<div class="flex space-x-1">
<button type="button" class="move-up p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move up">
<i class="fas fa-arrow-up text-secondary-500"></i>
</button>
<button type="button" class="move-down p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move down">
<i class="fas fa-arrow-down text-secondary-500"></i>
</button>
</div>
`;
selectedList.appendChild(listItem);
});
// Update hidden order inputs
updateOrderInputs();
};
// Update hidden inputs with the current order
const updateOrderInputs = () => {
const items = selectedList.querySelectorAll('.flex.items-center.justify-between');
if (items.length === 0) return;
// Remove any existing order input to avoid duplicates
const existingOrderInput = form.querySelector('input[name="config_order"]');
if (existingOrderInput) {
existingOrderInput.remove();
}
// Create a new input with the current order
const orderedIds = Array.from(items).map(item => item.getAttribute('data-id'));
// Create a hidden input to store the order
const configOrderInput = document.createElement('input');
configOrderInput.type = 'hidden';
configOrderInput.name = 'config_order';
configOrderInput.value = orderedIds.join(',');
// Add the input to the form
form.appendChild(configOrderInput);
// Update the visible order numbers
items.forEach((item, index) => {
const orderNum = index + 1;
const orderSpan = item.querySelector('span.rounded-full');
if (orderSpan) {
orderSpan.textContent = orderNum;
}
});
console.log('Updated order input:', configOrderInput.value);
};
// Initialize the selected list with saved order if available
updateSelectedItems(true);
// Handle checkbox changes
configList.addEventListener('change', (e) => {
if (e.target.matches('input[type="checkbox"]')) {
updateSelectedItems(false);
}
});
// Handle reordering
selectedList.addEventListener('click', (e) => {
const listItem = e.target.closest('.flex.items-center.justify-between');
if (!listItem) return;
if (e.target.closest('.move-up')) {
const prev = listItem.previousElementSibling;
if (prev) {
selectedList.insertBefore(listItem, prev);
updateOrderInputs();
}
} else if (e.target.closest('.move-down')) {
const next = listItem.nextElementSibling;
if (next) {
selectedList.insertBefore(next, listItem);
updateOrderInputs();
}
}
});
// Ensure the order input is updated before submission
form.addEventListener('submit', function(e) {
updateOrderInputs();
console.log('Form submitted with order:', form.querySelector('input[name="config_order"]')?.value);
});
};
// Setup ordering for new job form
setupJobOrdering('config-list', 'selected-configs', 'new-job-form', null);
// Setup ordering for edit job form
const editJobForm = document.getElementById('edit-job-form');
const savedOrderEdit = editJobForm ? editJobForm.getAttribute('data-config-order') : null;
setupJobOrdering('config-list-edit', 'selected-configs-edit', 'edit-job-form', savedOrderEdit);
});
</script>
}
templ JobForm(ctx context.Context, data JobFormData) {
@LayoutWithContext(getJobFormTitle(data.IsNew), 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-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>
@configSearchScript()
<!-- 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
class="space-y-6"
hx-post="/jobs"
hx-target="body"
hx-boost="true"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false"
@htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })"
x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }">
<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"
x-model="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 for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="config_id"
name="config_id"
x-model="configId"
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">
<option value="">Select a configuration</option>
for _, config := range data.Configs {
<option value={ fmt.Sprint(config.ID) }>{ config.Name }</option>
}
</select>
</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"
x-model="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"
x-model="enabled"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
checked/>
<input type="hidden" name="enabled" :value="enabled.toString()"/>
<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-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
</p>
</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"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-plus mr-2"></i>
Create Job
</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>
} else {
<form
class="space-y-6"
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
hx-target="body"
hx-boost="true"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false"
@htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })"
x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }>
<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"
x-model="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 for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
</div>
<select
id="config_id"
name="config_id"
x-model="configId"
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">
<option value="">Select a configuration</option>
for _, config := range data.Configs {
<option value={ fmt.Sprint(config.ID) } if data.Job != nil && data.Job.ConfigID == config.ID { selected }>{ config.Name }</option>
}
</select>
</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"
x-model="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"
x-model="enabled"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
<input type="hidden" name="enabled" :value="enabled.toString()"/>
<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-2 text-sm text-secondary-500 dark:text-secondary-400">
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
Disabled jobs will not run automatically.
</p>
</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"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center">
<i class="fas fa-save mr-2"></i>
Save Changes
</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>
}
</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 configuration
<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>
}
}
+156 -206
View File
@@ -15,221 +15,171 @@ type JobRunDetailsData struct {
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
@LayoutWithContext("Job Run Details", ctx) {
<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>
@JobRunDetailsContent(ctx, data)
}
}
// 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 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-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>
<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>
<!-- 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">
if data.Job.Name != "" {
{ data.Job.Name }
} else {
{ data.Config.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>
<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>
<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="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-sync-alt mr-1"></i> { data.JobHistory.Status }
</span>
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
}
</div>
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
Config: { data.Config.Name }
</p>
}
</dd>
</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="text-secondary-500 dark:text-secondary-400">Still running...</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 {
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
} else {
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
}
</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.Sprint(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">
if data.Config.SourceType == "sftp" {
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
} else {
{ 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">
if data.Config.DestinationType == "sftp" {
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
} else {
{ 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>
if data.Config.ArchiveEnabled {
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive 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.ArchivePath }
</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-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>
}
</dl>
</dd>
</div>
</div>
<!-- Error Information (if any) -->
if 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 Details
</h3>
</div>
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
<pre class="text-sm text-red-600 dark:text-red-400 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-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>
}
<!-- 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>
<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>
}
// formatDuration formats a duration in a human-readable way
+279 -185
View File
@@ -6,55 +6,46 @@ 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 top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<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">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<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">{ message }</h3>
<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>
<button type="button" onclick={ closeModal(id) } 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>
</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>
}
script hideJobDialog(id string) {
document.getElementById(id).classList.add("hidden");
}
script showJobDialog(id string) {
document.getElementById(id).classList.remove("hidden");
}
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Add debugging info
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
@@ -70,42 +61,89 @@ script triggerJobDelete(dialogId string, jobID uint, jobName string) {
}
type JobsData struct {
Jobs []db.Job
Jobs []db.Job
ConfigCount map[uint]int // Maps job ID to number of configs
}
templ Jobs(ctx context.Context, data JobsData) {
@LayoutWithContext("Transfer Jobs", ctx) {
@LayoutWithContext("Scheduled Jobs", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Debug notification system
console.log("Jobs template loaded, setting up notification system");
// Create a global notyf instance immediately
window.notyf = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'top',
},
types: [
{
type: 'success',
background: '#38c172',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
background: '#e3342f',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
console.log("Notyf initialized:", window.notyf);
// 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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Global function to handle job running
window.runJob = function(button) {
@@ -125,22 +163,33 @@ templ Jobs(ctx context.Context, data JobsData) {
displayName = event.detail.headers['HX-Job-Name'];
}
console.log(`Showing success notification for job: ${displayName}`);
window.notyf.success(`Job "${displayName}" started successfully!`);
showToast(`Job "${displayName}" started successfully!`, 'success');
} else {
let errorMsg = `Failed to start job "${jobName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
}
}, { once: true });
};
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
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');
});
});
});
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
console.log("HTMX before request:", event.detail);
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request by examining the URL and method
const path = event.detail.path;
const method = event.detail.verb;
@@ -157,9 +206,7 @@ templ Jobs(ctx context.Context, data JobsData) {
});
// Track HTMX after-request events for job deletion
document.addEventListener('htmx:afterRequest', function(event) {
console.log("HTMX after request:", event.detail);
document.addEventListener('htmx:afterRequest', function(event) {
// Check for job deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerJobDelete function
@@ -190,7 +237,7 @@ templ Jobs(ctx context.Context, data JobsData) {
}
console.log(`Showing success notification for deleted job: ${jobName}`);
window.notyf.success(`Job "${jobName}" deleted successfully`);
showToast(`Job "${jobName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingJob = false;
@@ -233,7 +280,7 @@ templ Jobs(ctx context.Context, data JobsData) {
}
console.log(`Showing error notification: ${errorMsg}`);
window.notyf.error(errorMsg);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingJob = false;
@@ -243,140 +290,187 @@ 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 }
<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 {
{ job.Config.Name }
}
</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 {
{ job.Config.Name }
<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>
}
</p>
if job.Enabled {
<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">
<i class="fas fa-check-circle mr-1"></i>
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">
<i class="fas fa-pause-circle 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="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-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
Config: { job.Config.Name }
</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>
}
</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 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>
<button
hx-post={ fmt.Sprintf("/jobs/%d/duplicate", job.ID) }
hx-swap="innerHTML"
hx-target="body"
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800"
data-job-id={ fmt.Sprint(job.ID) }
data-job-name={ job.Name }>
<i class="fas fa-clone mr-1"></i>
Duplicate
</button>
<!-- 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={ showModal(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>
</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 -383
View File
@@ -2,9 +2,22 @@ package components
import (
"context"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"time"
)
// AppVersion will be set at build time using ldflags
// Example build command:
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
var AppVersion = "DEV"
// GetReleaseURL returns the URL to the specific GitHub release
func GetReleaseURL() templ.SafeURL {
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
}
// CreateTemplateContext creates a new context with user information from Gin's context
func CreateTemplateContext(c *gin.Context) context.Context {
ctx := context.Background()
@@ -29,435 +42,465 @@ templ Layout(title string) {
templ LayoutWithContext(title string, ctx context.Context) {
<!DOCTYPE html>
<html lang="en" class="light h-full">
<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"/>
<meta name="theme-color" content="#2563eb"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
<link rel="manifest" href="/static/site.webmanifest"/>
<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>
<script src="https://cdn.tailwindcss.com"></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"/>
<!-- Notyf Toast Notifications -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js"></script>
<script>
// Initialize Notyf and make it available globally
document.addEventListener('DOMContentLoaded', function() {
window.notyfInstance = new Notyf({
duration: 3000,
position: {
x: 'right',
y: 'bottom',
},
types: [
{
type: 'success',
className: 'notyf__toast--success',
background: '#10B981',
icon: {
className: 'fas fa-check-circle',
tagName: 'i'
}
},
{
type: 'error',
className: 'notyf__toast--error',
background: '#EF4444',
icon: {
className: 'fas fa-exclamation-circle',
tagName: 'i'
}
}
]
});
});
</script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
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">
@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;
}
}
<!-- Font Awesome -->
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
<!-- Application assets -->
<link rel="stylesheet" href="/static/dist/app.css"/>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
/* 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;
<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>
<style>
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
width: 100%;
}
/* 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.400');
/* 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">
</head>
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
<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">
<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="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
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Administration
</p>
<a href="/admin/users" 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-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Users
</a>
<a href="/admin/tools" class="nav-link">
<i class="fas fa-tools mr-1"></i> Admin Tools
<a href="/admin/roles" 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-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Roles
</a>
<a href="/admin/audit" 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-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs
</a>
<a href="/admin/database" 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-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
// Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
// <li>
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
// </li>
<li>
<a href="/admin/settings/auth-providers" 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-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Authentication Providers
</a>
</li>
<li>
<a href="/admin/settings/notifications" 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-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Notification Services
</a>
</li>
</ul>
}
</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>
</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"
>
<div class="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>
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
<!-- Add this drawer element for mobile menu -->
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
<div class="flex items-center justify-between">
<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>
<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 type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
<i class="fas fa-times"></i>
</button>
</form>
</div>
<!-- Copy the same navigation items from the desktop sidebar -->
<nav class="mt-5">
<div class="space-y-1">
<!-- Copy the same links from the desktop sidebar -->
<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="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="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="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="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) {
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Administration
</p>
<a href="/admin/users" 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-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Users
</a>
<a href="/admin/roles" 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-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Roles
</a>
<a href="/admin/audit" 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-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs
</a>
<a href="/admin/database" 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-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools
</a>
// Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
// <li>
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
// </li>
<li>
<a href="/admin/settings/auth-providers" 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-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Authentication Providers
</a>
</li>
<li>
<a href="/admin/settings/notifications" 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-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Notification Services
</a>
</li>
</ul>
}
</div>
</nav>
</div>
<!-- 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">
<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"
hx-get="/notifications/dropdown"
hx-trigger="click once"
hx-target="#notification-dropdown-content"
data-dropdown-placement="bottom-end"
>
<span class="sr-only">View notifications</span>
<i class="fas fa-bell"></i>
<!-- Notification badge will be loaded dynamically -->
<div hx-get="/notifications/count" hx-trigger="load, notification-updated from:body" id="notification-count-container"></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 id="notification-dropdown-content">
<!-- Content will be loaded dynamically via HTMX -->
<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 class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<div class="animate-pulse flex flex-col items-center">
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
</div>
</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>
</div>
<!-- User menu dropdown -->
<div class="relative">
<button
type="button"
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
data-dropdown-toggle="user-dropdown"
data-dropdown-placement="bottom-end"
>
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
</div>
</button>
<!-- Dropdown menu -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
id="user-dropdown"
>
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a
href="/profile"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
<i class="fas fa-user-circle mr-2 w-4"></i>
Profile
</a>
</li>
<li>
<form method="POST" action="/logout">
<button
type="submit"
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
Sign out
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
<div class="py-6 bg-gray-50 dark:bg-gray-900">
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Notification area -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
</div>
}
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
<!-- Hidden notification area for HTMX targets -->
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
{ children... }
</main>
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
</div>
</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 mobile-nav-container z-10">
<div class="grid grid-cols-5 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>
<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>
} else {
<!-- Public page layout -->
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
<main class="flex-grow">
{ children... }
</main>
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>GoMFT &copy; { getCurrentYear() }</span>
<div class="flex items-center space-x-4">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion }
</a>
</div>
</div>
</div>
</footer>
</div>
}
<!-- Scripts -->
<!-- Alpine.js and dependencies -->
<script defer src="/static/dist/vendor.js"></script>
<!-- Application scripts -->
<script defer src="/static/dist/app.js"></script>
<script defer src="/static/dist/init.js"></script>
</body>
</html>
}
@@ -494,17 +537,17 @@ func isLoggedIn(ctx context.Context) bool {
func getUserInitial(ctx context.Context) string {
// Try as string first
if username, ok := ctx.Value("username").(string); ok && username != "" {
return string(username[0])
return strings.ToUpper(string(username[0]))
}
// Try as interface{} (from JWT claims)
if username, ok := ctx.Value("username").(interface{}); ok {
if strVal, ok := username.(string); ok && strVal != "" {
return string(strVal[0])
return strings.ToUpper(string(strVal[0]))
}
}
// Try email as fallback
if email, ok := ctx.Value("email").(string); ok && email != "" {
return string(email[0])
return strings.ToUpper(string(email[0]))
}
return "U"
}
@@ -513,12 +556,12 @@ func getUserInitial(ctx context.Context) string {
func getUserEmail(ctx context.Context) string {
// Try as string first
if email, ok := ctx.Value("email").(string); ok && email != "" {
return email
return strings.ToLower(email)
}
// Try as interface{} (from JWT claims)
if email, ok := ctx.Value("email").(interface{}); ok {
if strVal, ok := email.(string); ok && strVal != "" {
return strVal
return strings.ToLower(strVal)
}
}
return "user@example.com"
@@ -526,5 +569,5 @@ func getUserEmail(ctx context.Context) string {
// Helper function to get current year
func getCurrentYear() string {
return "2025"
}
return time.Now().Format("2006")
}
+114 -116
View File
@@ -7,131 +7,129 @@ 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 mb-3">
<i class="fas fa-info-circle mr-1"></i>
Contact an administrator to create an account
</p>
<!-- External Authentication Providers -->
<div id="external-auth-providers" class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
<div class="animate-pulse flex justify-center">
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
</div>
</div>
</div>
</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>
+985
View File
@@ -0,0 +1,985 @@
package components
import (
"context"
"fmt"
)
type NotificationFormData struct {
NotificationService *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}
IsNew bool
SuccessMessage string
ErrorMessage string
}
// Helper function to check if a string is in a slice
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
// Helper function to convert bool to string for HTML attributes
func boolToString(b bool) string {
if b {
return "true"
}
return ""
}
func getNotificationFormTitle(isNew bool) string {
if isNew {
return "Add Notification Service"
}
return "Edit Notification Service"
}
templ NotificationForm(ctx context.Context, data NotificationFormData) {
@LayoutWithContext(getNotificationFormTitle(data.IsNew), ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Notification system
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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
typeSelector.addEventListener('change', function() {
// Hide all fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = this.value;
if (selectedType) {
// Show common fields (name, description)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
});
// Initialize form if editing
if (document.getElementById('notification_type').value) {
// Trigger the change event to show the appropriate fields
document.getElementById('notification_type').dispatchEvent(new Event('change'));
}
});
</script>
<div id="notification-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="notifications-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full max-w-4xl mx-auto">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<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-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
{ getNotificationFormTitle(data.IsNew) }
</h1>
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Notification Services
</a>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<form id="notification-form"
if data.IsNew {
hx-post="/admin/settings/notifications"
} else {
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
}
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>
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
<option value="webhook" selected="selected">Webhook</option>
} else {
<option value="webhook">Webhook</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
<option value="pushbullet" selected="selected">Pushbullet</option>
} else {
<option value="pushbullet">Pushbullet</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
<option value="ntfy" selected="selected">Ntfy</option>
} else {
<option value="ntfy">Ntfy</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
<option value="gotify" selected="selected">Gotify</option>
} else {
<option value="gotify">Gotify</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
<option value="pushover" selected="selected">Pushover</option>
} else {
<option value="pushover">Pushover</option>
}
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
if data.NotificationService.Name != "" {
<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 Notification Service" required value={ data.NotificationService.Name }/>
} else {
<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 Notification Service" required value=""/>
}
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
if data.NotificationService.Description != "" {
<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">{ data.NotificationService.Description }</textarea>
} else {
<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="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>
if data.NotificationService.WebhookURL != "" {
<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" value={ data.NotificationService.WebhookURL }/>
} else {
<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" value=""/>
}
</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">
if data.NotificationService.Method != "" {
if data.NotificationService.Method == "POST" {
<option value="POST" selected="selected">POST</option>
} else {
<option value="POST">POST</option>
}
if data.NotificationService.Method == "PUT" {
<option value="PUT" selected="selected">PUT</option>
} else {
<option value="PUT">PUT</option>
}
} else {
<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>
if data.NotificationService.Headers != "" {
<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"}'>{ data.NotificationService.Headers }</textarea>
} else {
<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='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
>
if data.NotificationService.PayloadTemplate != "" {
data.NotificationService.PayloadTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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">
if data.NotificationService.EventTriggers != nil {
<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={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
}
<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">
if data.NotificationService.EventTriggers != nil {
<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={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
}
<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">
if data.NotificationService.EventTriggers != nil {
<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={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
}
<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>
if data.NotificationService.SecretKey != "" {
<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" value={ data.NotificationService.SecretKey }/>
}
<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">
if data.NotificationService.RetryPolicy != "" {
if data.NotificationService.RetryPolicy == "none" {
<option value="none" selected="selected">No retries</option>
} else {
<option value="none">No retries</option>
}
if data.NotificationService.RetryPolicy == "simple" {
<option value="simple" selected="selected">Simple (3 retries)</option>
} else {
<option value="simple">Simple (3 retries)</option>
}
if data.NotificationService.RetryPolicy == "exponential" {
<option value="exponential" selected="selected">Exponential backoff</option>
} else {
<option value="exponential">Exponential backoff</option>
}
} else {
<option value="none">No retries</option>
<option value="simple">Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
}
</select>
</div>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Pushbullet Fields -->
<div id="pushbullet_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
if data.NotificationService.PushbulletAPIKey != "" {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_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="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
} else {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_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="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
</div>
<div class="mb-6">
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
if data.NotificationService.PushbulletDeviceID != "" {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-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="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
} else {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-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="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.PushbulletTitleTemplate != "" {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-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={ data.NotificationService.PushbulletTitleTemplate }/>
} else {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-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.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushbullet_body_template"
name="pushbullet_body_template"
rows="4"
class="bg-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 '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushbulletBodyTemplate != "" {
data.NotificationService.PushbulletBodyTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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">
if data.NotificationService.EventTriggers != nil {
<input id="pb_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="pb_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"/>
}
<label for="pb_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">
if data.NotificationService.EventTriggers != nil {
<input id="pb_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="pb_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"/>
}
<label for="pb_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">
if data.NotificationService.EventTriggers != nil {
<input id="pb_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="pb_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"/>
}
<label for="pb_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Pushbullet -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushbullet-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushbullet configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Ntfy Fields -->
<div id="ntfy_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
if data.NotificationService.NtfyServer != "" {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-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://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
} else {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-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://ntfy.sh" value="https://ntfy.sh"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
</div>
<div class="mb-6">
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
if data.NotificationService.NtfyTopic != "" {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-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="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
} else {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-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="your-unique-topic" value="gomft"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
</div>
<div class="mb-6">
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="ntfy_priority" name="ntfy_priority" class="bg-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="1">Low (1)</option>
if data.NotificationService.NtfyPriority == "3" {
<option value="3" selected="selected">Default (3)</option>
} else {
<option value="3">Default (3)</option>
}
<option value="4">High (4)</option>
<option value="5">Urgent (5)</option>
</select>
</div>
<div class="mb-6">
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
if data.NotificationService.NtfyUsername != "" {
<input type="text" id="ntfy_username" name="ntfy_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="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
} else {
<input type="text" id="ntfy_username" name="ntfy_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="Username for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
if data.NotificationService.NtfyPassword != "" {
<input type="password" id="ntfy_password" name="ntfy_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="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
} else {
<input type="password" id="ntfy_password" name="ntfy_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="Password for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.NtfyTitleTemplate != "" {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-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={ data.NotificationService.NtfyTitleTemplate }/>
} else {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-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.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="ntfy_message_template"
name="ntfy_message_template"
rows="4"
class="bg-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 '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.NtfyMessageTemplate != "" {
data.NotificationService.NtfyMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="ntfy_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"/>
}
<label for="ntfy_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">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="ntfy_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"/>
}
<label for="ntfy_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">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="ntfy_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"/>
}
<label for="ntfy_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Ntfy -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-ntfy-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Ntfy configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Gotify Fields -->
<div id="gotify_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
if data.NotificationService.GotifyURL != "" {
<input type="url" id="gotify_url" name="gotify_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://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
} else {
<input type="url" id="gotify_url" name="gotify_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://gotify.example.com" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
</div>
<div class="mb-6">
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
if data.NotificationService.GotifyToken != "" {
<input type="text" id="gotify_token" name="gotify_token" class="bg-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="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
} else {
<input type="text" id="gotify_token" name="gotify_token" class="bg-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="A-M-XiEQj.zX5d" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
</div>
<div class="mb-6">
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="gotify_priority" name="gotify_priority" class="bg-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="0">Low (0)</option>
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
<option value="5" selected="selected">Normal (5)</option>
} else {
<option value="5">Normal (5)</option>
}
<option value="8">High (8)</option>
</select>
</div>
<div class="mb-6">
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.GotifyTitleTemplate != "" {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-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={ data.NotificationService.GotifyTitleTemplate }/>
} else {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-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.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="gotify_message_template"
name="gotify_message_template"
rows="4"
class="bg-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 '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.GotifyMessageTemplate != "" {
data.NotificationService.GotifyMessageTemplate
}</textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="gotify_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"/>
}
<label for="gotify_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">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="gotify_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"/>
}
<label for="gotify_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">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="gotify_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"/>
}
<label for="gotify_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Gotify -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-gotify-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Gotify configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Pushover Fields -->
<div id="pushover_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
if data.NotificationService.PushoverAPIToken != "" {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-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="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
} else {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-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="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
if data.NotificationService.PushoverUserKey != "" {
<input type="text" id="pushover_user_key" name="pushover_user_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="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
} else {
<input type="text" id="pushover_user_key" name="pushover_user_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="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
if data.NotificationService.PushoverDevice != "" {
<input type="text" id="pushover_device" name="pushover_device" class="bg-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="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
} else {
<input type="text" id="pushover_device" name="pushover_device" class="bg-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="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="pushover_priority" name="pushover_priority" class="bg-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="-2">Lowest (-2)</option>
<option value="-1">Low (-1)</option>
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
<option value="0" selected="selected">Normal (0)</option>
} else {
<option value="0">Normal (0)</option>
}
<option value="1">High (1)</option>
<option value="2">Emergency (2)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
<select id="pushover_sound" name="pushover_sound" class="bg-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="pushover">Pushover (default)</option>
<option value="bike">Bike</option>
<option value="bugle">Bugle</option>
<option value="cashregister">Cash Register</option>
<option value="classical">Classical</option>
<option value="cosmic">Cosmic</option>
<option value="falling">Falling</option>
<option value="gamelan">Gamelan</option>
<option value="incoming">Incoming</option>
<option value="intermission">Intermission</option>
<option value="magic">Magic</option>
<option value="mechanical">Mechanical</option>
<option value="pianobar">Piano Bar</option>
<option value="siren">Siren</option>
<option value="spacealarm">Space Alarm</option>
<option value="tugboat">Tug Boat</option>
<option value="alien">Alien Alarm (long)</option>
<option value="climb">Climb (long)</option>
<option value="persistent">Persistent (long)</option>
<option value="echo">Echo (long)</option>
<option value="updown">Up Down (long)</option>
<option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.PushoverTitleTemplate != "" {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-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={ data.NotificationService.PushoverTitleTemplate }/>
} else {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-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.name}}' {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushover_message_template"
name="pushover_message_template"
rows="4"
class="bg-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 '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushoverMessageTemplate != "" {
data.NotificationService.PushoverMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="pushover_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"/>
}
<label for="pushover_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">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="pushover_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"/>
}
<label for="pushover_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">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_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={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="pushover_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"/>
}
<label for="pushover_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Pushover -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushover-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushover configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<div class="flex items-center h-5">
if data.NotificationService.IsEnabled != false {
<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={ boolToString(data.NotificationService.IsEnabled) }/>
} else {
<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="true"/>
}
</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>
<div class="hidden common-fields">
<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 {
Add Service
} else {
Save Changes
}
</button>
</div>
</form>
</div>
<!-- Help Notice -->
<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">
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('notification-form-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('notification-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+483
View File
@@ -0,0 +1,483 @@
package components
import (
"context"
"fmt"
)
// Dialog component for confirmation dialogs using Flowbite modal
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="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">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<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">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<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">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
hx-target="body"
data-service-name={ serviceName }
data-service-id={ fmt.Sprint(serviceID) }
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
onclick={ triggerServiceDelete(id, serviceID, serviceName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } 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>
</div>
</div>
</div>
</div>
}
script triggerServiceDelete(dialogId string, serviceID uint, serviceName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Add debugging info
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedService = {
id: serviceID,
name: serviceName
};
// Add custom marker to track this deletion
window.currentlyDeletingService = true;
}
templ Notifications(ctx context.Context, data SettingsNotificationsData) {
@LayoutWithContext("Notification Services", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Notification system
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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a notification service
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
console.log("Detected notification service deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isServiceDeleteRequest = true;
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Check for notification service deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerServiceDelete function
window.currentlyDeletingService ||
// Check flag from beforeRequest handler
window.isServiceDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
console.log(`Showing success notification for deleted service: ${serviceName}`);
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingService ||
window.isServiceDeleteRequest ||
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText;
}
if (isDeleteRequest) {
console.log("Delete request failed");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
let errorMsg = `Failed to delete notification service "${serviceName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
} else {
showToast(errorMsg, 'error');
}
});
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
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');
});
});
// Show any success or error messages as toasts
if (document.querySelector('.success-message')) {
const successMsg = document.querySelector('.success-message').textContent.trim();
if (successMsg) {
showToast(successMsg, 'success');
}
}
if (document.querySelector('.error-message')) {
const errorMsg = document.querySelector('.error-message').textContent.trim();
if (errorMsg) {
showToast(errorMsg, 'error');
}
}
});
</script>
<div id="notifications-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="notifications-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<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-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notification Services
</h1>
<a href="/admin/settings/notifications/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>
Add Notification Service
</a>
</div>
<!-- List of Notification Services -->
if len(data.NotificationServices) == 0 {
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
<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 a notification service to receive alerts for job events.</p>
<a href="/admin/settings/notifications/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>
Add First Notification Service
</a>
</div>
} else {
<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 _, service := range data.NotificationServices {
<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">
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 mr-3">
<i class="fas fa-envelope"></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 mr-3">
<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 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
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"
>
<i class="fas fa-edit"></i>
</a>
<!-- Add notification delete dialog -->
@NotificationDialog(
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
"Delete Notification Service",
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
"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",
service.ID,
service.Name,
)
<button
type="button"
onclick={ showModal(fmt.Sprintf("delete-notification-dialog-%d", service.ID)) }
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"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<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>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<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>
} else {
<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>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
<!-- Help Notice -->
<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">
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('notifications-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('notifications-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
+255 -70
View File
@@ -5,53 +5,225 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for 2FA disable confirmation with Flowbite styling
templ TwoFactorDisableDialog() {
<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 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="bg-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" 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>
</div>
}
templ Profile(ctx context.Context, user db.User) {
@LayoutWithContext("Profile", ctx) {
<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>
<script>
// Initialize dialog functionality
document.addEventListener('DOMContentLoaded', function() {
console.log('Initializing 2FA dialog functionality');
// 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 = '';
};
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="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;
}
htmx.ajax('POST', '/profile/2fa/disable', {
target: '#disable-2fa-result',
swap: 'innerHTML',
values: { current_password: password }
});
};
// Close dialog when clicking outside
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
if (e.target === this) {
hideDisable2FADialog();
}
});
// Close dialog on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
hideDisable2FADialog();
}
});
});
</script>
@TwoFactorDisableDialog()
<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.IsAdmin {
<span class="badge badge-success">
<i class="fas fa-user-shield mr-1"></i> Administrator
</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-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="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 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="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 w-3 h-3 mr-1.5"></i>
Disable 2FA
</button>
</div>
</div>
} else {
<span class="badge badge-info">
<i class="fas fa-user mr-1"></i> Regular User
</span>
<div class="flex flex-col space-y-3">
<div class="flex items-center">
<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 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>
</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">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"
@@ -61,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">
@@ -75,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">
@@ -89,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>
@@ -102,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"
@@ -119,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: '',
@@ -138,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>
@@ -197,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>
@@ -214,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>
@@ -224,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>
+463
View File
@@ -0,0 +1,463 @@
package common
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
templ NameField() {
<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="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 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="bg-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-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="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 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="bg-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-2 text-sm text-gray-500 dark:text-gray-400">
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
</p>
<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>
</div>
}
templ ArchiveOptions() {
<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 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">
<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-2 text-sm text-gray-500 dark:text-gray-400">
Files will be moved here after successful transfer
</p>
</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>
</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="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>
</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="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 class="mb-6">
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</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-terminal text-gray-400 dark:text-gray-500"></i>
</div>
@RcloneCommandOptions()
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Select the rclone command to use for this configuration.
</p>
<!-- Command flags container - will be populated via HTMX -->
<div id="command-flags-container" class="mt-4"></div>
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional 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"
@change="updateCommandRequirements()"
class="bg-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-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Additional rclone flags for fine-tuning the transfer.
</p>
</div>
}
// New placeholder templ for rclone command options
templ RcloneCommandOptions() {
<div
hx-get="/api/rclone/commands"
hx-trigger="load"
hx-target="this"
hx-swap="outerHTML">
<!-- Loading placeholder -->
<option value="">Loading commands...</option>
</div>
}
templ SourceSelection() {
<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 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="bg-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>
<option value="s3">S3</option>
<option value="minio">MinIO</option>
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
</select>
</div>
</div>
}
templ DestinationSelection() {
<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 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="bg-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>
<option value="s3">S3</option>
<option value="minio">MinIO</option>
<option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option>
</select>
</div>
</div>
}
// RcloneCommandOptionsContent renders the command options organized by category
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) {
<select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container"
hx-trigger="change"
hx-include="[name='command_id']"
@change="updateCommandRequirements()"
class="bg-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="">Select command...</option>
for _, category := range categories {
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
<optgroup label={ category }>
for _, cmd := range commands {
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
}
</optgroup>
}
}
</select>
}
// RcloneCommandFlagsContent renders the command flags for a selected command
templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
if command == nil {
<div class="p-4 text-red-500">Command not found</div>
return
}
<div class="mb-4">
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
if len(command.Flags) > 0 {
<div class="mb-3">
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
<div class="space-y-3">
for _, flag := range command.Flags {
if flag.DataType == "bool" {
<!-- Boolean flag (simple checkbox) -->
<div class="flex items-start mb-4">
<input
type="checkbox"
id={ fmt.Sprintf("flag_%d", flag.ID) }
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
/>
<div class="ml-3">
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description }
</label>
if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
}
</div>
</div>
} else {
<!-- Non-boolean flag (requires value) -->
<div class="flex items-start mb-4 w-full">
<div class="w-full">
<div class="flex items-center mb-2">
<input
type="checkbox"
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
onclick="toggleFlagValue(this)"
/>
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description }
</label>
</div>
<div class="w-full mt-2">
@renderFlagInput(flag)
if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
}
</div>
</div>
</div>
}
}
</div>
</div>
<!-- JavaScript to handle enabling/disabling flag value inputs -->
<script>
function toggleFlagValue(checkbox) {
const inputId = checkbox.getAttribute('data-input-id');
const input = document.getElementById(inputId);
if (input) {
input.disabled = !checkbox.checked;
if (checkbox.checked) {
// Focus on the input when enabled
input.focus();
}
// Also enable/disable the hidden input for flag ID
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.disabled = !checkbox.checked;
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
}
// For debugging
console.log('Toggle flag value:', inputId, checkbox.checked);
}
}
// Initialize all flag inputs on page load
document.addEventListener('DOMContentLoaded', function() {
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
checkboxes.forEach(function(checkbox) {
const inputId = checkbox.getAttribute('data-input-id');
const input = document.getElementById(inputId);
if (input) {
input.disabled = !checkbox.checked;
// Also initialize the hidden input
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.disabled = !checkbox.checked;
}
}
});
});
</script>
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
<div class="flex items-center mb-1">
<i class="fas fa-info-circle mr-2"></i>
<span class="font-medium">Usage Examples</span>
</div>
if command.Name == "ls" ||
command.Name == "lsd" ||
command.Name == "lsl" ||
command.Name == "lsjson" ||
command.Name == "lsf" ||
command.Name == "delete" ||
command.Name == "purge" ||
command.Name == "rmdirs" ||
command.Name == "mkdir" ||
command.Name == "touch" ||
command.Name == "md5sum" ||
command.Name == "sha1sum" ||
command.Name == "sha256sum" ||
command.Name == "size" ||
command.Name == "stat" ||
command.Name == "version" {
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
} else {
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
}
</div>
} else {
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>This command doesn't have any specific flags.</span>
</div>
</div>
}
</div>
}
// Helper function to render appropriate input based on flag data type
templ renderFlagInput(flag db.RcloneCommandFlag) {
if flag.DataType == "int" {
<input
type="number"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
disabled
/>
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
disabled
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
} else if flag.DataType == "float" {
<input
type="number"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
step="0.01"
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
disabled
/>
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
disabled
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
} else {
<!-- Default to text input for string and other types -->
<input
type="text"
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue }
disabled
/>
<!-- Hidden input to include this flag ID when checked -->
<input
type="hidden"
name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) }
disabled
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/>
}
}
@@ -0,0 +1,3 @@
// Package destination contains the UI destination forms for the GoMFT application.
// This file serves as a marker for the destination package to ensure it's properly recognized by Go.
package destination
@@ -0,0 +1,79 @@
package destination
templ FTPDestinationForm() {
<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 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" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-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>
<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 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" id="dest_port" name="dest_port" x-model="destPort"
class="bg-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>
<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 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="destUser" x-bind:required="requiresDestination"
class="bg-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>
<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="destPassword" x-bind:required="requiresDestination"
class="bg-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="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>
<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" x-bind:required="requiresDestination"
class="bg-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>
}
@@ -0,0 +1,106 @@
package destination
templ GoogleDriveDestinationForm() {
<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>
<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 x-show="!useBuiltinAuthDest">
<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="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="destClientId"
class="bg-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="!useBuiltinAuthDest" />
</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="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="destClientSecret"
class="bg-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="!useBuiltinAuthDest" />
</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>
<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>
<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="destDriveId"
class="bg-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>
<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="destTeamDrive"
class="bg-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>
}
@@ -0,0 +1,188 @@
package destination
templ GooglePhotosDestinationForm() {
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
if(value) {
destClientId = '';
destClientSecret = '';
}
})">
<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>
<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 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="destClientId"
class="bg-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="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="destClientSecret"
class="bg-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="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="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="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="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 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 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>
}
@@ -0,0 +1,41 @@
package destination
templ LocalDestinationForm() {
<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 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" x-bind:required="requiresDestination"
:class="{ 'border-red-500 dark:border-red-700': destPathValid === false, 'border-green-500 dark:border-green-700': destPathValid === 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="destPathValid === 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="destPathError"></span>
</div>
</div>
<div x-show="destPathValid === 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="destPathError || 'Path is valid'"></span>
</div>
</div>
</div>
<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, 'dest')"
>
<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>
}
@@ -0,0 +1,97 @@
package destination
templ MinIODestinationForm() {
<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="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 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" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
class="bg-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>
<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="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 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" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-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="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 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="destAccessKey" x-bind:required="requiresDestination"
class="bg-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="destSecretKey" x-bind:required="requiresDestination"
class="bg-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>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package destination
templ NextCloudDestinationForm() {
<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="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 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" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
class="bg-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="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 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="destUser" x-bind:required="requiresDestination"
class="bg-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="destPassword" x-bind:required="requiresDestination"
class="bg-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" x-bind:required="requiresDestination"
class="bg-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>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package destination
templ S3DestinationForm() {
<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="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 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="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
class="bg-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="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 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" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-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="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 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="destAccessKey" x-bind:required="requiresDestination"
class="bg-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="destSecretKey" x-bind:required="requiresDestination"
class="bg-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>
</div>
</div>
</div>
}
@@ -0,0 +1,97 @@
package destination
templ SFTPDestinationForm() {
<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 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" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-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 class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<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 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" id="dest_port" name="dest_port" x-model="destPort"
class="bg-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 class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<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 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="destUser" x-bind:required="requiresDestination"
class="bg-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">SFTP username for authentication</p>
</div>
<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>
<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 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 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="destPassword"
class="bg-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>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<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 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_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 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" x-bind:required="requiresDestination"
class="bg-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>
}
+112
View File
@@ -0,0 +1,112 @@
package destination
templ SMBDestinationForm() {
<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="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 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" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-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>
<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="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 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" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
class="bg-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-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="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 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" id="dest_domain" name="dest_domain" x-model="destDomain"
class="bg-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="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 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="destUser" x-bind:required="requiresDestination"
class="bg-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="destPassword" x-bind:required="requiresDestination"
class="bg-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>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package destination
templ WebDAVDestinationForm() {
<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="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 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="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
class="bg-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-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<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 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="destUser" x-bind:required="requiresDestination"
class="bg-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="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="destPassword" x-bind:required="requiresDestination"
class="bg-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>
</div>
</div>
</div>
}
+191
View File
@@ -0,0 +1,191 @@
package providers
import (
"fmt"
"strings"
"github.com/starfleetcptn/gomft/components/providers/common"
"github.com/starfleetcptn/gomft/components/providers/source"
"github.com/starfleetcptn/gomft/components/providers/destination"
)
// Returns the form ID based on the form type and whether it's a source or destination
func formID(formType string, isSource bool) string {
if isSource {
return "source_config_form"
}
return "destination_config_form"
}
// Returns a user-friendly display name for the provider
func providerDisplayName(provider string) string {
switch provider {
case "sftp":
return "SFTP"
case "local":
return "Local Filesystem"
case "s3":
return "Amazon S3"
case "ftp":
return "FTP"
case "azure":
return "Azure Blob Storage"
default:
return strings.Title(provider)
}
}
templ ProviderForm(formType string, providers []string, isSource bool) {
<form
id={formID(formType, isSource)}
x-data={fmt.Sprintf("{ %sProvider: '', showAdvanced: false }", formType)}
class="space-y-8">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4">
@common.NameField()
<div class="sm:col-span-4">
<label for={fmt.Sprintf("%s_provider", formType)} class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Provider 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>
<select
id={fmt.Sprintf("%s_provider", formType)}
name={fmt.Sprintf("%s_provider", formType)}
x-model={fmt.Sprintf("%sProvider", formType)}
class="bg-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>
}
</select>
</div>
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'sftp'", formType)}>
if isSource {
@source.SFTPSourceForm()
} else {
@destination.SFTPDestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'local'", formType)}>
if isSource {
@source.LocalSourceForm()
} else {
@destination.LocalDestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 's3'", formType)}>
if isSource {
@source.S3SourceForm()
} else {
@destination.S3DestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'ftp'", formType)}>
if isSource {
@source.FTPSourceForm()
} else {
@destination.FTPDestinationForm()
}
</div>
<div class="sm:col-span-12" x-show={fmt.Sprintf("%sProvider", formType)}>
<div class="mt-6">
<label for="show_advanced" class="flex items-center cursor-pointer">
<div class="relative">
<input id="show_advanced" type="checkbox" x-model="showAdvanced" class="sr-only" />
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
:class="showAdvanced ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
Show Advanced Options
</div>
</label>
</div>
<div x-show="showAdvanced">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4 mt-6">
@common.FilePatternFields()
if isSource {
@common.ArchiveOptions()
}
</div>
</div>
</div>
</div>
</form>
}
script formAlpineInit() {
return {
initProviderForm() {
// Initialize with values if editing existing config
if (window.editData && window.editData.configs) {
const config = window.editData.configs.find(c =>
isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id)
);
if (config) {
this[formType + 'Provider'] = config.provider;
this.name = config.name;
// Provider-specific fields
if (config.provider === 'sftp') {
this.host = config.host;
this.port = config.port;
this.username = config.username;
this.path = config.path;
if (config.key_file && config.key_file !== '') {
this.authType = 'key_file';
this.keyFile = config.key_file;
} else {
this.authType = 'password';
// Password is not included in edit data for security
}
} else if (config.provider === 'local') {
this.path = config.path;
} else if (config.provider === 's3') {
this.bucket = config.bucket;
this.region = config.region;
this.path = config.path;
this.accessKey = config.access_key;
if (config.endpoint && config.endpoint !== '') {
this.useCustomEndpoint = true;
this.endpoint = config.endpoint;
} else {
this.useCustomEndpoint = false;
}
} else if (config.provider === 'ftp') {
this.host = config.host;
this.port = config.port;
this.username = config.username;
this.path = config.path;
this.useFTPS = config.use_ftps;
}
// Advanced options
if (config.include_pattern) this.filePattern = config.include_pattern;
if (config.exclude_pattern) this.excludePattern = config.exclude_pattern;
if (isSource && config.extract_archives) {
this.extractArchives = true;
this.deleteArchives = config.delete_archives;
}
}
}
},
providerChanged() {
console.log("Provider changed to: " + this[formType + 'Provider']);
}
};
}
+3
View File
@@ -0,0 +1,3 @@
// Package providers contains the UI providers for the GoMFT application.
// This file serves as a marker for the providers package to ensure it's properly recognized by Go.
package providers
+79
View File
@@ -0,0 +1,79 @@
package source
templ FTPSourceForm() {
<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 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" 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>
<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 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" 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>
<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 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="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
</div>
<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 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="Enter password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
</div>
<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>
<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>
}
+106
View File
@@ -0,0 +1,106 @@
package source
templ GoogleDriveSourceForm() {
<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>
<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 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="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="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>
<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>
<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>
<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>
}
+188
View File
@@ -0,0 +1,188 @@
package source
templ GooglePhotosSourceForm() {
<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>
<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 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="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="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="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="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="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 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 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>
}
+41
View File
@@ -0,0 +1,41 @@
package source
templ LocalSourceForm() {
<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 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>
</div>
</div>
<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>
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ MinIOSourceForm() {
<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="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 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" 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>
<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="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 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" 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="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 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>
</div>
</div>
</div>
}
@@ -0,0 +1,82 @@
package source
templ NextCloudSourceForm() {
<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="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 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" 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-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your NextCloud server including protocol (https://)
</p>
</div>
<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 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="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="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="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>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ S3SourceForm() {
<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="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="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 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" 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 S3 bucket (case-sensitive)
</p>
</div>
<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 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="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="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 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="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="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-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>
</div>
</div>
</div>
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ SFTPSourceForm() {
<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 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" 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 class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
</div>
<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 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" 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 class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
</div>
<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 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="username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
</div>
<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>
<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 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 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"
class="bg-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>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
</div>
<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 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_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 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>
}
+112
View File
@@ -0,0 +1,112 @@
package source
templ SMBSourceForm() {
<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="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 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" 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>
<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="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 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" 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-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="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 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" 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="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 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>
</div>
</div>
</div>
}
+3
View File
@@ -0,0 +1,3 @@
// Package source contains the UI source forms for the GoMFT application.
// This file serves as a marker for the source package to ensure it's properly recognized by Go.
package source
+82
View File
@@ -0,0 +1,82 @@
package source
templ WebDAVSourceForm() {
<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="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 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_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-2 text-sm text-gray-500 dark:text-gray-400">
Full URL to your WebDAV server including protocol (https://)
</p>
</div>
<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 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="webdav_username" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username
</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="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>
</div>
</div>
</div>
}
+152 -82
View File
@@ -6,98 +6,168 @@ import (
templ Register(ctx context.Context, errorMessage string) {
@LayoutWithContext("Register", ctx) {
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new user account
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Complete the form below to create a new user
</p>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
Create Account
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Complete the form below to create a new user account
</p>
<!-- Registration Card -->
<div class="max-w-md mx-auto">
if errorMessage != "" {
<div class="mt-4">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{ errorMessage }</span>
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ errorMessage }</span>
</div>
</div>
}
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email" class="sr-only">Email address</label>
<input
id="email"
name="email"
type="email"
required
x-model="email"
class="form-input rounded-t-md"
placeholder="Email address"/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
x-model="password"
class="form-input"
placeholder="Password"/>
</div>
<div>
<label for="confirm-password" class="sr-only">Confirm Password</label>
<input
id="confirm-password"
name="confirm_password"
type="password"
required
x-model="confirmPassword"
class="form-input rounded-b-md"
placeholder="Confirm Password"/>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Registration
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
</div>
</div>
</div>
<div class="p-6">
<form
hx-post="/register"
hx-target="body"
class="space-y-4"
x-data="{
email: '',
password: '',
confirmPassword: '',
loading: false,
validate() {
return this.email &&
this.password &&
this.confirmPassword &&
this.password === this.confirmPassword;
}
}"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false">
<div>
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Email Address
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-envelope text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="email"
id="email"
name="email"
x-model="email"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="your.email@example.com"
required
/>
</div>
</div>
<div>
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="password"
name="password"
x-model="password"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required
/>
</div>
</div>
<div>
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Confirm Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="password"
id="confirm-password"
name="confirm_password"
x-model="confirmPassword"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="••••••••"
required
/>
</div>
<template x-if="confirmPassword && password !== confirmPassword">
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
<i class="fas fa-exclamation-circle mr-1"></i>
Passwords do not match
</p>
</template>
</div>
<button
type="submit"
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
x-bind:disabled="!validate() || loading">
<span x-show="!loading" class="flex items-center justify-center">
<i class="fas fa-user-plus mr-2"></i>
Create Account
</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
</form>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
<div class="text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Already have an account?
<a href="/login" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
Sign in
</a>
</p>
</div>
</div>
</div>
</div>
<!-- Help Notice -->
<div class="mt-6 max-w-md mx-auto">
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
<div>
<button
type="submit"
class="btn-primary w-full flex justify-center"
x-bind:disabled="!validate() || loading">
<span x-show="!loading">Create User</span>
<span x-show="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
</button>
<h4 class="text-sm font-medium mb-1">Account Information</h4>
<p>Creating an account will allow you to manage file transfers and configurations. Your email address will only be used for authentication and important notifications related to your transfers.</p>
</div>
</form>
<div class="text-center">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</div>
</div>
</div>
+773
View File
@@ -0,0 +1,773 @@
package components
import (
"context"
"fmt"
)
// NotificationService defines a notification service configuration
type NotificationService struct {
ID uint
Name string
Type string // "email", "webhook", etc.
IsEnabled bool
Config map[string]string
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
// SettingsNotificationsData contains data for the notifications page in settings
type SettingsNotificationsData struct {
NotificationServices []NotificationService
ErrorMessage string
SuccessMessage string
}
// SettingsData contains data for the settings page
type SettingsData struct {
NotificationServices []NotificationService
ErrorMessage string
SuccessMessage string
}
templ Settings(ctx context.Context, data SettingsData) {
@LayoutWithContext("Application Settings", ctx) {
<div id="settings-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog w-6 h-6 mr-2 text-blue-500"></i>
Application Settings
</h1>
<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" disabled>
<i class="fas fa-sliders-h mr-2"></i>General (Coming Soon)
</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" disabled>
<i class="fas fa-shield-alt mr-2"></i>Security (Coming Soon)
</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>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 border border-gray-200 rounded-lg shadow-sm dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Add Notification Service
</h3>
<form id="add-notification-form" hx-post="/admin/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="webhook">Webhook</option>
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<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 Notification Service" required/>
</div>
<div class="mb-6 hidden common-fields">
<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="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='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
></textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use { `variable` } placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</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>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs 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"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<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>
<div class="hidden common-fields">
<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">Add Service</button>
</div>
</form>
</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">Use the form above to add your first notification service.</p>
</div>
} else {
<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 _, service := range data.NotificationServices {
<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">
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 mr-3">
<i class="fas fa-envelope"></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 mr-3">
<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 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<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={ "/admin/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 class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<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>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<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>
} else {
<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>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</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>
<!-- Authentication Sources -->
<div class="mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Authentication Sources</h3>
<button type="button" data-modal-target="add-auth-source-modal" data-modal-toggle="add-auth-source-modal" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 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>Add Source
</button>
</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-4 border-b border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">
Configure external authentication providers to allow users to log in using existing accounts.
</p>
</div>
<!-- Authentication Sources List -->
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Local Authentication (always present) -->
<li>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<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 mr-3">
<i class="fas fa-key"></i>
</div>
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
Local Authentication
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Default username and password authentication
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
Active
</span>
</div>
</div>
</div>
</li>
<!-- Authentik SSO (if configured) -->
<!-- This would be populated from backend data in a real implementation -->
<li>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<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 mr-3">
<i class="fas fa-passport"></i>
</div>
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">
Authentik SSO
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Single Sign-On with Authentik Identity Provider
</p>
</div>
</div>
<div class="ml-2 flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
Active
</span>
<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 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-auth-source-modal"
data-modal-toggle="edit-auth-source-modal"
>
<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="/admin/settings/auth-sources/1"
hx-confirm="Are you sure you want to delete this authentication source?"
hx-target="body"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex gap-4">
<div class="flex items-center text-xs">
<span class="text-gray-500 dark:text-gray-400">Client ID:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">gomft_app</span>
</div>
<div class="flex items-center text-xs">
<span class="text-gray-500 dark:text-gray-400">Provider URL:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">https://auth.example.com</span>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<form class="space-y-6" hx-post="/admin/settings/security" hx-target="body">
<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>
</div>
}
@toggleNotificationFields()
@addAuthSourceModals()
@setupTabSwitching()
}
// Add new templ component for auth source modals
templ addAuthSourceModals() {
<!-- Add Authentication Source Modal -->
<div id="add-auth-source-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-800">
<div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-700">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Add Authentication Source
</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-700 dark:hover:text-white" data-modal-hide="add-auth-source-modal">
<i class="fas fa-times"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<form hx-post="/admin/settings/auth-sources" hx-target="body">
<div class="p-6 space-y-6">
<div class="mb-6">
<label for="auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<select id="auth_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="authentik">Authentik SSO</option>
<option value="oauth2">Generic OAuth 2.0</option>
<option value="oidc">OpenID Connect</option>
<option value="saml" disabled>SAML 2.0 (Coming Soon)</option>
</select>
</div>
<div id="authentik_fields" class="auth-type-fields">
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">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 dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Authentik SSO" required/>
</div>
<div class="mb-6">
<label for="provider_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentik Provider URL</label>
<input type="url" id="provider_url" name="provider_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://auth.example.com" required/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The base URL of your Authentik instance</p>
</div>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
<input type="text" id="client_id" name="client_id" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="gomft_app" required/>
</div>
<div>
<label for="client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
<input type="password" id="client_secret" name="client_secret" class="bg-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" required/>
</div>
</div>
<div class="mb-6">
<label for="redirect_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Redirect URL</label>
<input type="text" id="redirect_url" name="redirect_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/auth/callback" disabled/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Configure this URL in your Authentik provider settings</p>
</div>
<div class="mb-6">
<label for="scopes" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Scopes</label>
<input type="text" id="scopes" name="scopes" class="bg-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="openid email profile" required/>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Attribute Mapping</label>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="username_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username Attribute</label>
<input type="text" id="username_attribute" name="username_attribute" class="bg-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="preferred_username" required/>
</div>
<div>
<label for="email_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Attribute</label>
<input type="text" id="email_attribute" name="email_attribute" class="bg-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="email" required/>
</div>
</div>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div>
<label for="name_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name Attribute</label>
<input type="text" id="name_attribute" name="name_attribute" class="bg-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="name" required/>
</div>
<div>
<label for="groups_attribute" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Groups Attribute</label>
<input type="text" id="groups_attribute" name="groups_attribute" class="bg-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="groups" required/>
</div>
</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 authentication source</label>
</div>
</div>
<div class="p-4 mb-6 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>
<p>To configure Authentik for this application:</p>
<ol class="list-decimal ml-5 mt-2">
<li>Create a new OAuth2/OIDC Provider in Authentik</li>
<li>Set the redirect URI to the value shown above</li>
<li>Obtain the Client ID and Secret from Authentik</li>
<li>Configure group mappings if needed for permission control</li>
</ol>
</div>
</div>
</div>
</div>
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-700">
<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</button>
<button type="button" 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-800 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-modal-hide="add-auth-source-modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Authentication Source Modal would be similar to the Add modal -->
}
// Script to toggle notification fields based on selection
script toggleNotificationFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
typeSelector.addEventListener('change', function() {
// Hide all fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = this.value;
if (selectedType) {
// Show common fields (name, description)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
});
});
}
// Add script to toggle authentication source fields based on type selection
script toggleAuthSourceFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('auth_type');
const authFields = document.querySelectorAll('.auth-type-fields');
if (typeSelector) {
typeSelector.addEventListener('change', function() {
// Hide all auth type fields first
authFields.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');
}
}
});
}
});
}
// Add script to handle tab switching
script setupTabSwitching() {
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('[role="tab"]');
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
function switchTab(targetId) {
// Hide all tab panels
tabPanels.forEach(panel => {
panel.classList.add('hidden');
});
// Show the target panel
const targetPanel = document.querySelector(targetId);
if (targetPanel) {
targetPanel.classList.remove('hidden');
targetPanel.classList.add('block');
}
// Update tab button styles
tabButtons.forEach(button => {
// Get the target from the button
const buttonTarget = button.getAttribute('data-tabs-target');
if (buttonTarget === targetId) {
// Active tab
button.classList.add('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
button.classList.remove('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
button.setAttribute('aria-selected', 'true');
} else {
// Inactive tab
button.classList.remove('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
button.classList.add('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
button.setAttribute('aria-selected', 'false');
}
});
}
// Add click event listeners to tab buttons
tabButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('data-tabs-target');
switchTab(targetId);
});
});
// Initialize the tabs (select the one with aria-selected="true")
const initialTab = document.querySelector('[role="tab"][aria-selected="true"]');
if (initialTab) {
const targetId = initialTab.getAttribute('data-tabs-target');
switchTab(targetId);
}
});
}
+200
View File
@@ -0,0 +1,200 @@
package components
import "context"
import "strings"
type TwoFactorBackupCodesData struct {
BackupCodes []string
ErrorMessage string
SuccessMessage string
}
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
<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="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>
<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>
<!-- 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 New Codes
</button>
</form>
</div>
</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>
}
}
+126
View File
@@ -0,0 +1,126 @@
package components
import "context"
type BackupCodeVerifyData struct {
ErrorMessage string
}
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
@LayoutWithContext("Backup Code Verification", ctx) {
<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>
}
<!-- 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>
</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>
<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>
<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>
<!-- 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>
}
}
+215
View File
@@ -0,0 +1,215 @@
package components
import "context"
type TwoFactorSetupData struct {
QRCodeURL string
Secret string
BackupCodes []string
ErrorMessage string
}
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
<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>
}
<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>
<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>
</div>
}
}
+124
View File
@@ -0,0 +1,124 @@
package components
import "context"
type TwoFactorVerifyData struct {
ErrorMessage string
}
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
@LayoutWithContext("Two-Factor Authentication", ctx) {
<div class="p-4 md:p-6 2xl:p-10">
<!-- Page Header -->
<div class="mb-6 flex flex-col items-center justify-center text-center">
<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 text-center">
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>
}
<!-- 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>
</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>
</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
</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 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>
</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>
</div>
}
}
+200
View File
@@ -0,0 +1,200 @@
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>
}
}
+680
View File
@@ -0,0 +1,680 @@
package components
import (
"context"
"fmt"
"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
SuccessMessage string
}
templ UserManagement(ctx context.Context, data UserManagementData) {
@LayoutWithContext("User Management", ctx) {
@UserManagementContent(data)
}
}
// UserManagementContent is a partial component for HTMX to replace only the content
templ UserManagementContent(data UserManagementData) {
// <div id="userManagementContainer" class="px-4 py-8 mx-auto max-w-screen-xl lg:px-6">
<div id="userManagementContainer" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Toast container for status messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// 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 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
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);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
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');
});
});
});
// Track user operations in HTMX events
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a user
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for user deletions (e.g., /admin/users/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/users\/\d+$/)) {
console.log("Detected user deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isUserDeleteRequest = true;
}
});
// Track HTMX events for user operations
document.addEventListener('htmx:afterRequest', function(event) {
// Check if the request was successful
if (event.detail.successful) {
// Check for POST, PUT, DELETE operations
const path = event.detail.pathInfo?.requestPath || '';
const method = event.detail.verb || '';
// User deletion handling
const isDeleteRequest =
window.currentlyDeletingUser ||
window.isUserDeleteRequest ||
(method === 'DELETE' && path.match(/^\/admin\/users\/\d+$/));
if (isDeleteRequest) {
let userEmail = "user";
// Try multiple sources for user email
if (event.detail.elt && event.detail.elt.getAttribute) {
userEmail = event.detail.elt.getAttribute('data-user-email') || userEmail;
}
if (userEmail === "user" && window.lastDeletedUser) {
// Fallback to our stored user info
userEmail = window.lastDeletedUser.email;
}
showToast(`User "${userEmail}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingUser = false;
window.isUserDeleteRequest = false;
window.lastDeletedUser = null;
return;
}
// User creation
if (method === 'POST' && path === '/admin/users') {
// Find the email from the form data if possible
const formData = event.detail.requestConfig?.parameters;
let email = "New user";
if (formData && formData.email) {
email = formData.email;
}
showToast(`User "${email}" created successfully`, 'success');
return;
}
// User update
if (method === 'PUT' && path.match(/^\/admin\/users\/\d+$/)) {
// Find the email from the form data if possible
const formData = event.detail.requestConfig?.parameters;
let email = "User";
if (formData && formData.email) {
email = formData.email;
}
showToast(`User "${email}" updated successfully`, 'success');
return;
}
}
});
// Track HTMX error events
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Check for delete request errors
const isDeleteRequest =
window.currentlyDeletingUser ||
window.isUserDeleteRequest ||
(event.detail.requestConfig &&
event.detail.requestConfig.verb === 'DELETE' &&
event.detail.pathInfo?.requestPath?.match(/^\/admin\/users\/\d+$/));
if (isDeleteRequest) {
let userEmail = "user";
// Try multiple sources for user email
if (event.detail.elt && event.detail.elt.getAttribute) {
userEmail = event.detail.elt.getAttribute('data-user-email') || userEmail;
}
if (userEmail === "user" && window.lastDeletedUser) {
userEmail = window.lastDeletedUser.email;
}
let errorMsg = `Failed to delete user "${userEmail}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingUser = false;
window.isUserDeleteRequest = false;
window.lastDeletedUser = null;
return;
}
// Generic error handling
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
showToast(errorMsg, 'error');
});
</script>
<!-- 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="#userManagementContainer"
hx-push-url="true"
>
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
Add User
</button>
</div>
</div>
<!-- Check for URL parameters after page load to show success/error messages -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
const successMsg = urlParams.get('success');
const errorMsg = urlParams.get('error');
// Show success toast if parameter exists
if (successMsg) {
showToast(decodeURIComponent(successMsg), 'success');
}
// Show error toast if parameter exists
if (errorMsg) {
showToast(decodeURIComponent(errorMsg), 'error');
}
});
</script>
<!-- Convert existing messages to toasts if present -->
if data.SuccessMessage != "" {
<script>
// Show success message as toast
document.addEventListener('DOMContentLoaded', function() {
showToast("{ data.SuccessMessage }", 'success');
});
</script>
}
if data.ErrorMessage != "" {
<script>
// Show error message as toast
document.addEventListener('DOMContentLoaded', function() {
showToast("{ data.ErrorMessage }", 'error');
});
</script>
}
<!-- Main Content Container -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 dark:bg-gray-800">
<!-- Include the appropriate tab -->
if data.ActiveTab == "list" || data.ActiveTab == "" {
@userList(data.Users)
} else if data.ActiveTab == "create" {
@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>
</div>
}
// User listing component
templ userList(users []db.User) {
<!-- Dialog Container for User Actions -->
for _, user := range users {
@UserDialog(
fmt.Sprintf("delete-user-dialog-%d", user.ID),
"Delete User",
fmt.Sprintf("Are you sure you want to delete user \"%s\"? This action cannot be undone.", user.Email),
"bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700",
"Delete User",
"delete",
user.ID,
user.Email,
)
}
<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="#userManagementContainer"
hx-push-url="true"
>
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
</button>
<button
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
data-user-email={ user.Email }
data-user-id={ strconv.Itoa(int(user.ID)) }
onclick={ showModal(fmt.Sprintf("delete-user-dialog-%d", user.ID)) }
>
<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-4 md:space-y-6"
if isNew {
hx-post="/admin/users"
} else {
hx-put={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
}
hx-target="#userManagementContainer"
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="#userManagementContainer"
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
}
// UserDialog for confirmation actions
templ UserDialog(id string, title string, message string, confirmClass string, confirmText string, action string, userID uint, userEmail string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="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">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<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">
if action == "delete" {
<i class="fas fa-user-slash text-red-400 text-3xl mb-4"></i>
} else {
<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">{ message }</h3>
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
hx-delete={ fmt.Sprintf("/admin/users/%d", userID) }
hx-target="#userManagementContainer"
data-user-email={ userEmail }
id={ fmt.Sprintf("delete-user-btn-%d", userID) }
onclick={ triggerUserDelete(id, userID, userEmail) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } 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>
</div>
</div>
</div>
</div>
}
script triggerUserDelete(dialogId string, userID uint, userEmail 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.lastDeletedUser = {
id: userID,
email: userEmail
};
// Add custom marker to track this deletion
window.currentlyDeletingUser = true;
}
+148 -173
View File
@@ -1,9 +1,9 @@
package components
import (
"context"
"github.com/starfleetcptn/gomft/internal/db"
"strconv"
"context"
)
type UsersData struct {
@@ -12,88 +12,98 @@ type UsersData struct {
templ Users(ctx context.Context, data UsersData) {
@LayoutWithContext("User Management", ctx) {
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
User Management
</h1>
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
<div class="mt-6">
if len(data.Users) == 0 {
<div class="card p-12 flex flex-col items-center justify-center text-center">
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
</div>
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
<div class="mt-6">
<a href="/admin/users/new" class="btn-primary">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
<div class="p-4 md:p-6 2xl:p-10">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-users w-6 h-6 mr-2 text-blue-500"></i>
User Management
</h1>
<a href="/admin/users/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
Add User
</a>
</div>
<div class="mt-6">
if len(data.Users) == 0 {
<div class="flex p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900 dark:text-yellow-300" role="alert">
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
<span class="sr-only">Info</span>
<div>
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
</div>
} else {
<div class="card overflow-hidden">
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead class="bg-secondary-50 dark:bg-secondary-800">
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-envelope mr-1"></i> Email
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
Email
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-user-tag mr-1"></i> Role
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
Role
</div>
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
<i class="fas fa-calendar-plus mr-1"></i> Created
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<i class="fas fa-calendar-plus w-4 h-4 mr-1"></i>
Created
</div>
</th>
<th scope="col" class="relative px-6 py-3">
<th scope="col" class="px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody>
if len(data.Users) == 0 {
<tr>
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
No users found
</td>
</tr>
} else {
for _, user := range data.Users {
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{ user.Email }
</td>
<td class="px-6 py-4 whitespace-nowrap">
if user.IsAdmin {
<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
<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="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>
}
}
}
+21 -4
View File
@@ -3,22 +3,37 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
# Set the UID/GID to match your host user for better file permissions
# Default is 1000:1000 if not specified
UID: ${UID:-1000}
GID: ${GID:-1000}
# Version information
VERSION: ${VERSION:-dev}
BUILD_TIME: ${BUILD_TIME:-unknown}
COMMIT: ${COMMIT:-unknown}
container_name: gomft
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# Persist data directory for SQLite database and configurations
# Main data directory - contains DB and configs
- gomft-data:/app/data
# Separate backups directory
- gomft-backups:/app/backups
# For development, you can mount the source code
# - .:/app
environment:
- TZ=UTC
# Add any environment variables needed for configuration
# - GOMFT_DB_PATH=/app/data/gomft.db
# - GOMFT_LOG_LEVEL=info
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- LOGS_DIR=/app/data/logs
# - LOG_LEVEL=info
networks:
- gomft-network
# For non-root installs, the container needs to run with the same UID
# as the host user to access mounted volumes properly
user: ${UID:-1000}:${GID:-1000}
networks:
gomft-network:
@@ -26,4 +41,6 @@ networks:
volumes:
gomft-data:
driver: local
gomft-backups:
driver: local
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
set -e
# Default username
USERNAME=${USERNAME:-gomft}
# If PUID/PGID env vars are set, update the user's UID/GID
if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
echo "🔒 Updating user ${USERNAME} with UID:GID = ${PUID}:${PGID}"
# Make sure we have directories to work with
mkdir -p /app/data /app/backups
# Check if we're on Alpine (busybox)
if grep -q "Alpine" /etc/os-release 2>/dev/null; then
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
# Update group ID first
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
echo "Updating GID to ${PGID}..."
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
fi
# Update user ID
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
echo "Updating UID to ${PUID}..."
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
fi
else
echo "Non-Alpine system, using standard user management..."
# Handle user/group changes with error recovery
{
# First remove the user (since user has the group as primary group)
if getent passwd ${USERNAME} > /dev/null; then
echo "Removing existing user ${USERNAME}"
userdel ${USERNAME} 2>/dev/null || true
fi
# Wait a moment for system to clean up user
sleep 1
# Then remove the group
if getent group ${USERNAME} > /dev/null; then
echo "Removing existing group ${USERNAME}"
groupdel ${USERNAME} 2>/dev/null || true
fi
# Recreate group and user in the correct order
echo "Creating group ${USERNAME} with GID ${PGID}"
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
echo "Creating user ${USERNAME} with UID ${PUID}"
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
} || {
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
}
fi
# Fix ownership of app directories
echo "Setting ownership of app directories"
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
# Ensure .env file exists and has correct permissions
if [ -f /app/.env ]; then
echo "Found .env file, setting permissions..."
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
else
echo "No .env file found, creating empty one..."
touch /app/.env
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
fi
# Run the application as the specified user
echo "Starting application as user ${USERNAME}"
if command -v su-exec >/dev/null 2>&1; then
exec su-exec ${USERNAME} "$@"
elif command -v gosu >/dev/null 2>&1; then
exec gosu ${USERNAME} "$@"
else
exec su -m ${USERNAME} -c "$*"
fi
else
# Run as the predefined user (set during build)
echo "Starting application with predefined user"
exec "$@"
fi
-18
View File
@@ -1,18 +0,0 @@
{
"server_address": ":8080",
"data_dir": "/app/data/gomft",
"backup_dir": "/app/data/gomft/backups",
"jwt_secret": "change_this_to_a_secure_random_string",
"email": {
"enabled": true,
"host": "smtp.example.com",
"port": 587,
"from_email": "gomft@example.com",
"from_name": "GoMFT",
"reply_to": "",
"enable_tls": true,
"require_auth": true,
"username": "smtp_username",
"password": "smtp_password"
}
}
+12 -4
View File
@@ -4,16 +4,21 @@ go 1.24.0
require (
github.com/a-h/templ v0.3.833
github.com/gin-contrib/sessions v1.0.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.3
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.4.0
github.com/robfig/cron/v3 v3.0.1
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
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.12.9 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
@@ -26,6 +31,9 @@ require (
github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -39,9 +47,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
+26 -8
View File
@@ -1,5 +1,7 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -15,6 +17,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sessions v1.0.2 h1:UaIjUvTH1cMeOdj3in6dl+Xb6It8RiKRF9Z1anbUyCA=
github.com/gin-contrib/sessions v1.0.2/go.mod h1:KxKxWqWP5LJVDCInulOl4WbLzK2KSPlLesfZ66wRvMs=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -40,14 +44,24 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -67,6 +81,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -90,19 +106,21 @@ 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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+14 -22
View File
@@ -14,22 +14,22 @@ import (
)
type RegisterRequest struct {
Email string `json:"email" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
Token string `json:"token"`
User UserResponse `json:"user"`
}
type UserResponse struct {
ID uint `json:"id"`
ID uint `json:"id"`
Email string `json:"email"`
}
@@ -94,7 +94,7 @@ func handleRegister(database *db.DB) gin.HandlerFunc {
// Create user
user := &db.User{
Email: req.Email,
Email: req.Email,
PasswordHash: string(hashedPassword),
}
@@ -136,7 +136,7 @@ func handleLogin(database *db.DB, jwtSecret string) gin.HandlerFunc {
c.JSON(http.StatusOK, LoginResponse{
Token: token,
User: UserResponse{
ID: user.ID,
ID: user.ID,
Email: user.Email,
},
})
@@ -384,7 +384,7 @@ func handleCreateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Schedule the job if enabled
if job.Enabled {
if job.GetEnabled() {
if err := scheduler.ScheduleJob(&job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule job"})
return
@@ -484,7 +484,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
}
// Check if schedule or enabled status changed
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.Enabled != existingJob.Enabled
scheduleChanged := updatedJob.Schedule != existingJob.Schedule || updatedJob.GetEnabled() != existingJob.GetEnabled()
if err := database.UpdateJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
@@ -493,7 +493,7 @@ func handleUpdateJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
// Update the scheduler if needed
if scheduleChanged {
if updatedJob.Enabled {
if updatedJob.GetEnabled() {
if err := scheduler.ScheduleJob(&updatedJob); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job schedule"})
return
@@ -610,12 +610,8 @@ func handleEnableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handle
return
}
// Update job status
job.Enabled = true
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Enable the job
job.SetEnabled(true)
// Add to scheduler
if err := scheduler.ScheduleJob(job); err != nil {
@@ -654,12 +650,8 @@ func handleDisableJob(database *db.DB, scheduler *scheduler.Scheduler) gin.Handl
return
}
// Update job status
job.Enabled = false
if err := database.UpdateJob(job); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
return
}
// Disable the job
job.SetEnabled(false)
// Remove from scheduler
scheduler.UnscheduleJob(jobID)
@@ -719,7 +711,7 @@ func handleListHistory(database *db.DB) gin.HandlerFunc {
// For now, just return the most recent 100 history entries for the user's jobs
var history []db.JobHistory
err := database.DB.
Joins("JOIN jobs ON job_histories.job_id = jobs.id").
Joins("JOIN jobs ON job_history.job_id = jobs.id").
Where("jobs.created_by = ?", userID).
Order("start_time DESC").
Limit(100).
+28 -37
View File
@@ -6,46 +6,46 @@ import (
"regexp"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// PasswordPolicy defines the requirements for password strength and management
type PasswordPolicy struct {
MinLength int // Minimum password length
RequireUppercase bool // Require at least one uppercase letter
RequireLowercase bool // Require at least one lowercase letter
RequireNumbers bool // Require at least one number
RequireSpecial bool // Require at least one special character
ExpirationDays int // Number of days until password expires (0 = never)
HistoryCount int // Number of previous passwords to remember (0 = disabled)
DisallowCommon bool // Disallow common passwords
MaxLoginAttempts int // Maximum failed login attempts before lockout
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
MinLength int // Minimum password length
RequireUppercase bool // Require at least one uppercase letter
RequireLowercase bool // Require at least one lowercase letter
RequireNumbers bool // Require at least one number
RequireSpecial bool // Require at least one special character
ExpirationDays int // Number of days until password expires (0 = never)
HistoryCount int // Number of previous passwords to remember (0 = disabled)
DisallowCommon bool // Disallow common passwords
MaxLoginAttempts int // Maximum failed login attempts before lockout
LockoutDuration time.Duration // Duration of account lockout after max failed attempts
}
// PasswordHistory represents a historical password entry
type PasswordHistory struct {
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
PasswordHash string `gorm:"not null"`
ID uint `gorm:"primarykey"`
UserID uint `gorm:"not null"`
PasswordHash string `gorm:"not null"`
CreatedAt time.Time
}
// DefaultPasswordPolicy returns the default password policy
func DefaultPasswordPolicy() PasswordPolicy {
return PasswordPolicy{
MinLength: 8,
RequireUppercase: true,
RequireLowercase: true,
RequireNumbers: true,
RequireSpecial: true,
ExpirationDays: 90,
HistoryCount: 5,
DisallowCommon: true,
MaxLoginAttempts: 5,
LockoutDuration: 15 * time.Minute,
MinLength: 8,
RequireUppercase: true,
RequireLowercase: true,
RequireNumbers: true,
RequireSpecial: true,
ExpirationDays: 90,
HistoryCount: 5,
DisallowCommon: true,
MaxLoginAttempts: 5,
LockoutDuration: 15 * time.Minute,
}
}
@@ -127,7 +127,7 @@ func IsPasswordExpired(lastPasswordChange time.Time, policy PasswordPolicy) bool
if policy.ExpirationDays <= 0 {
return false
}
expirationTime := lastPasswordChange.Add(time.Duration(policy.ExpirationDays) * 24 * time.Hour)
return time.Now().After(expirationTime)
}
@@ -143,7 +143,7 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
UserID: userID,
PasswordHash: hashedPassword,
}
if err := db.Create(&passwordHistory).Error; err != nil {
return err
}
@@ -151,13 +151,13 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
// Trim history if needed
var count int64
db.Model(&PasswordHistory{}).Where("user_id = ?", userID).Count(&count)
if count > int64(policy.HistoryCount) {
var oldestHistories []PasswordHistory
if err := db.Where("user_id = ?", userID).Order("created_at asc").Limit(int(count) - policy.HistoryCount).Find(&oldestHistories).Error; err != nil {
return err
}
for _, history := range oldestHistories {
if err := db.Delete(&history).Error; err != nil {
return err
@@ -168,15 +168,6 @@ func UpdatePasswordHistory(userID uint, hashedPassword string, db *gorm.DB, poli
return nil
}
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}
// ComparePasswords compares a hashed password with a plain text password
func ComparePasswords(hashedPassword, plainPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
+324
View File
@@ -0,0 +1,324 @@
package auth
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base32"
"encoding/base64"
"fmt"
"image/png"
"io"
"strings"
// "github.com/pquerna/otp/base32"
"github.com/pquerna/otp/totp"
"github.com/starfleetcptn/gomft/internal/config"
"golang.org/x/crypto/bcrypt"
)
const (
// IssuerName is the name of the issuer that appears in authenticator apps
IssuerName = "GoMFT"
// SecretSize is the size of the TOTP secret in bytes
SecretSize = 20
// BackupCodeCount is the number of backup codes to generate
BackupCodeCount = 8
// BackupCodeLength is the length of each backup code
BackupCodeLength = 8
)
// EncryptTOTPSecret encrypts the TOTP secret with AES-256-GCM
func EncryptTOTPSecret(secret string) (string, error) {
// Get encryption key from config or environment variable
var key []byte
appConfig, err := config.Load()
if err != nil {
return "", fmt.Errorf("failed to load config: %v", err)
}
// Use the configured encryption key
key = []byte(appConfig.TOTPEncryptKey)
// If empty for some reason, log warning and use development key
if len(key) == 0 {
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
key = []byte("this-is-a-dev-key-not-for-production!")
}
// Ensure key is exactly 32 bytes (AES-256)
if len(key) < 32 {
// If key is too short, pad it to 32 bytes
paddedKey := make([]byte, 32)
copy(paddedKey, key)
for i := len(key); i < 32; i++ {
paddedKey[i] = byte(i % 256) // Simple padding pattern
}
key = paddedKey
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
} else if len(key) > 32 {
// If key is too long, truncate to 32 bytes
key = key[:32]
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
}
// Create a new cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %v", err)
}
// Create a new GCM
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %v", err)
}
// Create a nonce
nonce := make([]byte, aesGCM.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %v", err)
}
// Encrypt the data
ciphertext := aesGCM.Seal(nil, nonce, []byte(secret), nil)
// Combine nonce and ciphertext and encode as base64
result := base64.StdEncoding.EncodeToString(append(nonce, ciphertext...))
return result, nil
}
// DecryptTOTPSecret decrypts the TOTP secret with AES-256-GCM
func DecryptTOTPSecret(encryptedSecret string) (string, error) {
// Get encryption key from config or environment variable
var key []byte
appConfig, err := config.Load()
if err != nil {
return "", fmt.Errorf("failed to load config: %v", err)
}
// Use the configured encryption key
key = []byte(appConfig.TOTPEncryptKey)
// If empty for some reason, log warning and use development key
if len(key) == 0 {
fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.")
key = []byte("this-is-a-dev-key-not-for-production!")
}
// Ensure key is exactly 32 bytes (AES-256)
if len(key) < 32 {
// If key is too short, pad it to 32 bytes
paddedKey := make([]byte, 32)
copy(paddedKey, key)
for i := len(key); i < 32; i++ {
paddedKey[i] = byte(i % 256) // Simple padding pattern
}
key = paddedKey
fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.")
} else if len(key) > 32 {
// If key is too long, truncate to 32 bytes
key = key[:32]
fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.")
}
// Decode the base64 string
decoded, err := base64.StdEncoding.DecodeString(encryptedSecret)
if err != nil {
return "", fmt.Errorf("failed to decode base64 secret: %v", err)
}
// Create a new cipher block
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %v", err)
}
// Create a new GCM
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %v", err)
}
// Get the nonce size
nonceSize := aesGCM.NonceSize()
if len(decoded) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
// Extract nonce and ciphertext
nonce, ciphertext := decoded[:nonceSize], decoded[nonceSize:]
// Decrypt the data
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt data: %v", err)
}
return string(plaintext), nil
}
// GenerateTOTPSecret generates a new TOTP secret for a user
func GenerateTOTPSecret(email string) (string, string, error) {
// Generate TOTP key using the library
key, err := totp.Generate(totp.GenerateOpts{
Issuer: IssuerName,
AccountName: email,
})
if err != nil {
return "", "", fmt.Errorf("failed to generate TOTP key: %v", err)
}
// Generate QR code image
var buf bytes.Buffer
img, err := key.Image(256, 256)
if err != nil {
return "", "", fmt.Errorf("failed to generate QR code image: %v", err)
}
// Encode image as PNG and convert to base64
err = png.Encode(&buf, img)
if err != nil {
return "", "", fmt.Errorf("failed to encode QR code image: %v", err)
}
// Create data URL
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
return key.Secret(), dataURL, nil
}
// ValidateTOTPCode validates a TOTP code against an encrypted secret
func ValidateTOTPCode(encryptedSecret string, code string) bool {
// Remove any spaces from the code
code = strings.ReplaceAll(code, " ", "")
// Decrypt the secret
secret, err := DecryptTOTPSecret(encryptedSecret)
if err != nil {
// Log the error but fail silently to the user
fmt.Printf("Error decrypting TOTP secret: %v\n", err)
return false
}
// Use the library's Validate function
return totp.Validate(code, secret)
}
// BackupCodePair represents a backup code and its hash
type BackupCodePair struct {
PlainCode string
HashedCode string
}
// GenerateBackupCodes generates a set of backup codes
// Returns both plaintext codes (to show to user) and hashed codes (to store in DB)
func GenerateBackupCodes() ([]string, string, error) {
plainCodes := make([]string, BackupCodeCount)
hashedCodes := make([]string, BackupCodeCount)
for i := 0; i < BackupCodeCount; i++ {
// Generate random bytes
bytes := make([]byte, BackupCodeLength/2)
_, err := rand.Read(bytes)
if err != nil {
return nil, "", fmt.Errorf("failed to generate backup code: %v", err)
}
// Convert to hex string
plainCodes[i] = fmt.Sprintf("%x", bytes)
// Hash the code for storage
hash, err := bcrypt.GenerateFromPassword([]byte(plainCodes[i]), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("failed to hash backup code: %v", err)
}
// Store the hashed version
hashedCodes[i] = string(hash)
}
// Return plaintext codes for display and hashed codes for storage
return plainCodes, strings.Join(hashedCodes, ","), nil
}
// ValidateBackupCode validates a backup code against a list of hashed codes
func ValidateBackupCode(providedCode string, storedHashedCodes string) bool {
if storedHashedCodes == "" {
return false
}
// Remove any spaces and convert to lowercase
providedCode = strings.ToLower(strings.ReplaceAll(providedCode, " ", ""))
// Split stored hashed codes
hashedCodes := strings.Split(storedHashedCodes, ",")
// Check if the provided code matches any stored hashed code
for _, hashedCode := range hashedCodes {
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(providedCode)); err == nil {
// If the code matches (no error from bcrypt), return true
return true
}
}
return false
}
// RemoveBackupCode removes a used backup code from the list
func RemoveBackupCode(usedCode string, storedHashedCodes string) string {
if storedHashedCodes == "" {
return ""
}
usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", ""))
hashedCodes := strings.Split(storedHashedCodes, ",")
var remainingHashedCodes []string
for _, hashedCode := range hashedCodes {
// Only add the code back to the list if it doesn't match the used code
if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(usedCode)); err != nil {
// If there's an error, this isn't the used code, so keep it
remainingHashedCodes = append(remainingHashedCodes, hashedCode)
}
}
return strings.Join(remainingHashedCodes, ",")
}
// GenerateQRCodeURL generates a QR code URL for an existing secret
func GenerateQRCodeURL(secret string, email string) (string, error) {
// Decode the base32 secret
secretBytes, err := base32.StdEncoding.DecodeString(secret)
if err != nil {
return "", fmt.Errorf("failed to decode secret: %v", err)
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: IssuerName,
AccountName: email,
Secret: secretBytes,
})
if err != nil {
return "", fmt.Errorf("failed to generate TOTP key: %v", err)
}
// Generate QR code image
var buf bytes.Buffer
img, err := key.Image(256, 256)
if err != nil {
return "", fmt.Errorf("failed to generate QR code image: %v", err)
}
// Encode image as PNG and convert to base64
err = png.Encode(&buf, img)
if err != nil {
return "", fmt.Errorf("failed to encode QR code image: %v", err)
}
// Create data URL
dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
return dataURL, nil
}
+125 -53
View File
@@ -1,84 +1,156 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
ServerAddress string `json:"server_address"`
DataDir string `json:"data_dir"`
BackupDir string `json:"backup_dir"`
JWTSecret string `json:"jwt_secret"`
Email EmailConfig `json:"email"`
BaseURL string `json:"base_url"` // Base URL for generating links in emails
ServerAddress string `json:"server_address"`
DataDir string `json:"data_dir"`
BackupDir string `json:"backup_dir"`
JWTSecret string `json:"jwt_secret"`
Email EmailConfig `json:"email"`
BaseURL string `json:"base_url"` // Base URL for generating links in emails
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets
}
type EmailConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"from_email"`
FromName string `json:"from_name"`
ReplyTo string `json:"reply_to,omitempty"`
EnableTLS bool `json:"enable_tls"`
RequireAuth bool `json:"require_auth"`
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromEmail string `json:"from_email"`
FromName string `json:"from_name"`
ReplyTo string `json:"reply_to,omitempty"`
EnableTLS bool `json:"enable_tls"`
RequireAuth bool `json:"require_auth"`
}
func Load() (*Config, error) {
// Default configuration
cfg := &Config{
ServerAddress: ":8080",
DataDir: filepath.Join("./data", "gomft"),
BackupDir: filepath.Join("./data", "gomft", "backups"),
JWTSecret: "change_this_to_a_secure_random_string",
BaseURL: "http://localhost:8080",
ServerAddress: ":8080",
DataDir: "./data",
BackupDir: "./backups",
JWTSecret: "change_this_to_a_secure_random_string",
BaseURL: "http://localhost:8080",
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
Email: EmailConfig{
Enabled: false,
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "your-password",
FromEmail: "gomft@example.com",
FromName: "GoMFT",
EnableTLS: true,
Enabled: false,
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "your-password",
FromEmail: "gomft@example.com",
FromName: "GoMFT",
EnableTLS: true,
RequireAuth: true,
},
}
// Check if config file exists
configPath := filepath.Join(cfg.DataDir, "config.json")
if _, err := os.Stat(configPath); err == nil {
// Read configuration file
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
// Parse configuration
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, err
}
// Ensure data directory exists
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return nil, err
}
// Save configuration if it doesn't exist
if _, err := os.Stat(configPath); os.IsNotExist(err) {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
// First try to load .env from the root directory
envPath := ".env"
if _, err := os.Stat(envPath); err == nil {
// Load .env file
if err := godotenv.Load(envPath); err != nil {
return nil, err
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
// Override configuration with environment variables
if serverAddr := os.Getenv("SERVER_ADDRESS"); serverAddr != "" {
cfg.ServerAddress = serverAddr
}
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
cfg.DataDir = dataDir
}
if backupDir := os.Getenv("BACKUP_DIR"); backupDir != "" {
cfg.BackupDir = backupDir
}
if jwtSecret := os.Getenv("JWT_SECRET"); jwtSecret != "" {
cfg.JWTSecret = jwtSecret
}
if baseURL := os.Getenv("BASE_URL"); baseURL != "" {
cfg.BaseURL = baseURL
}
if totpKey := os.Getenv("TOTP_ENCRYPTION_KEY"); totpKey != "" {
cfg.TOTPEncryptKey = totpKey
}
// Email configuration
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
cfg.Email.Enabled = strings.ToLower(emailEnabled) == "true"
}
if emailHost := os.Getenv("EMAIL_HOST"); emailHost != "" {
cfg.Email.Host = emailHost
}
if emailPort := os.Getenv("EMAIL_PORT"); emailPort != "" {
if port, err := strconv.Atoi(emailPort); err == nil {
cfg.Email.Port = port
}
}
if emailUsername := os.Getenv("EMAIL_USERNAME"); emailUsername != "" {
cfg.Email.Username = emailUsername
}
if emailPassword := os.Getenv("EMAIL_PASSWORD"); emailPassword != "" {
cfg.Email.Password = emailPassword
}
if emailFromEmail := os.Getenv("EMAIL_FROM_EMAIL"); emailFromEmail != "" {
cfg.Email.FromEmail = emailFromEmail
}
if emailFromName := os.Getenv("EMAIL_FROM_NAME"); emailFromName != "" {
cfg.Email.FromName = emailFromName
}
if emailReplyTo := os.Getenv("EMAIL_REPLY_TO"); emailReplyTo != "" {
cfg.Email.ReplyTo = emailReplyTo
}
if emailEnableTLS := os.Getenv("EMAIL_ENABLE_TLS"); emailEnableTLS != "" {
cfg.Email.EnableTLS = strings.ToLower(emailEnableTLS) == "true"
}
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true"
}
} else if !os.IsNotExist(err) {
return nil, err
} else {
// Create default .env file in root directory if it doesn't exist
envContent := []string{
"SERVER_ADDRESS=" + cfg.ServerAddress,
"DATA_DIR=" + cfg.DataDir,
"BACKUP_DIR=" + cfg.BackupDir,
"JWT_SECRET=" + cfg.JWTSecret,
"BASE_URL=" + cfg.BaseURL,
"",
"# Google OAuth configuration (optional, for built-in authentication)",
"GOOGLE_CLIENT_ID=your_google_client_id",
"GOOGLE_CLIENT_SECRET=your_google_client_secret",
"",
"# Two-Factor Authentication configuration",
"TOTP_ENCRYPTION_KEY=" + cfg.TOTPEncryptKey,
"",
"# Email configuration",
"EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled),
"EMAIL_HOST=" + cfg.Email.Host,
"EMAIL_PORT=" + strconv.Itoa(cfg.Email.Port),
"EMAIL_FROM_EMAIL=" + cfg.Email.FromEmail,
"EMAIL_FROM_NAME=" + cfg.Email.FromName,
"EMAIL_REPLY_TO=" + cfg.Email.ReplyTo,
"EMAIL_ENABLE_TLS=" + strconv.FormatBool(cfg.Email.EnableTLS),
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
"EMAIL_USERNAME=" + cfg.Email.Username,
"EMAIL_PASSWORD=" + cfg.Email.Password,
}
if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
return nil, err
}
}
+46
View File
@@ -0,0 +1,46 @@
package db
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
// AuditLog represents an audit trail entry in the system
type AuditLog struct {
gorm.Model
Action string `gorm:"size:50;not null;index"`
EntityType string `gorm:"size:50;not null;index"`
EntityID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
Details AuditLogDetails `gorm:"type:json"`
Timestamp time.Time `gorm:"not null;index;default:CURRENT_TIMESTAMP"`
}
// AuditLogDetails is a custom type for storing audit log details as JSON
type AuditLogDetails map[string]interface{}
// Scan implements the sql.Scanner interface
func (d *AuditLogDetails) Scan(value interface{}) error {
if value == nil {
*d = make(AuditLogDetails)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(bytes, d)
}
// Value implements the driver.Valuer interface
func (d AuditLogDetails) Value() (driver.Value, error) {
if d == nil {
return json.Marshal(make(map[string]interface{}))
}
return json.Marshal(d)
}
+154
View File
@@ -0,0 +1,154 @@
package db
import (
"database/sql"
"encoding/json"
"time"
)
// ProviderType represents the type of authentication provider
type ProviderType string
const (
// ProviderTypeAuthentik represents an Authentik authentication provider
ProviderTypeAuthentik ProviderType = "authentik"
// ProviderTypeOIDC represents an OpenID Connect authentication provider
ProviderTypeOIDC ProviderType = "oidc"
// ProviderTypeSAML represents a SAML authentication provider
ProviderTypeSAML ProviderType = "saml"
// ProviderTypeOAuth2 represents an OAuth2 authentication provider
ProviderTypeOAuth2 ProviderType = "oauth2"
)
// AuthProvider represents an external authentication provider configuration
type AuthProvider struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"not null" json:"name"`
Type ProviderType `gorm:"not null" json:"type"`
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `json:"description"`
ProviderURL string `json:"provider_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"-"` // Not returned in JSON responses
RedirectURL string `json:"redirect_url"`
Scopes string `json:"scopes"`
AttributeMapping string `json:"attribute_mapping"`
Config string `json:"-"` // Stores provider-specific configuration
IconURL string `json:"icon_url"` // URL to provider icon
SuccessfulLogins int `json:"successful_logins"`
LastUsed sql.NullTime `json:"last_used"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Unmarshalled config
configData map[string]interface{} `gorm:"-" json:"-"`
}
// GetConfig returns the unmarshalled configuration data
func (p *AuthProvider) GetConfig() (map[string]interface{}, error) {
if p.configData == nil && p.Config != "" {
err := json.Unmarshal([]byte(p.Config), &p.configData)
if err != nil {
return nil, err
}
}
if p.configData == nil {
p.configData = make(map[string]interface{})
}
return p.configData, nil
}
// SetConfig sets the configuration data and marshals it to JSON
func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
p.Config = string(jsonData)
p.configData = data
return nil
}
// ExternalUserIdentity represents a user identity from an external authentication provider
type ExternalUserIdentity struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
ProviderID uint `gorm:"not null" json:"provider_id"`
ProviderType ProviderType `gorm:"not null" json:"provider_type"`
ExternalID string `gorm:"not null" json:"external_id"`
Email string `gorm:"not null" json:"email"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Groups string `json:"groups"` // JSON array of groups
LastLogin sql.NullTime `json:"last_login"`
ProviderData string `json:"-"` // Raw data from provider
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Foreign key relationships
User User `gorm:"foreignKey:UserID" json:"-"`
Provider AuthProvider `gorm:"foreignKey:ProviderID" json:"-"`
// Unmarshalled provider data
providerDataObj map[string]interface{} `gorm:"-" json:"-"`
}
// GetProviderData returns the unmarshalled provider data
func (e *ExternalUserIdentity) GetProviderData() (map[string]interface{}, error) {
if e.providerDataObj == nil && e.ProviderData != "" {
err := json.Unmarshal([]byte(e.ProviderData), &e.providerDataObj)
if err != nil {
return nil, err
}
}
if e.providerDataObj == nil {
e.providerDataObj = make(map[string]interface{})
}
return e.providerDataObj, nil
}
// SetProviderData sets the provider data and marshals it to JSON
func (e *ExternalUserIdentity) SetProviderData(data map[string]interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
e.ProviderData = string(jsonData)
e.providerDataObj = data
return nil
}
// GetGroups returns the unmarshalled groups
func (e *ExternalUserIdentity) GetGroups() ([]string, error) {
var groups []string
if e.Groups == "" {
return groups, nil
}
err := json.Unmarshal([]byte(e.Groups), &groups)
if err != nil {
return nil, err
}
return groups, nil
}
// SetGroups sets the groups and marshals them to JSON
func (e *ExternalUserIdentity) SetGroups(groups []string) error {
jsonData, err := json.Marshal(groups)
if err != nil {
return err
}
e.Groups = string(jsonData)
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package db
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
)
// GetAllAuthProviders returns all authentication providers
func (db *DB) GetAllAuthProviders(ctx context.Context) ([]AuthProvider, error) {
var providers []AuthProvider
tx := db.WithContext(ctx).Order("name asc").Find(&providers)
if tx.Error != nil {
return nil, fmt.Errorf("failed to get auth providers: %w", tx.Error)
}
return providers, nil
}
// GetAuthProviderByID retrieves an authentication provider by ID
func (db *DB) GetAuthProviderByID(ctx context.Context, id uint) (*AuthProvider, error) {
var provider AuthProvider
tx := db.WithContext(ctx).First(&provider, id)
if tx.Error != nil {
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("auth provider not found: %d", id)
}
return nil, fmt.Errorf("failed to get auth provider: %w", tx.Error)
}
return &provider, nil
}
// CreateAuthProvider creates a new authentication provider
func (db *DB) CreateAuthProvider(ctx context.Context, provider *AuthProvider) error {
tx := db.WithContext(ctx).Create(provider)
if tx.Error != nil {
return fmt.Errorf("failed to create auth provider: %w", tx.Error)
}
return nil
}
// UpdateAuthProvider updates an existing authentication provider
func (db *DB) UpdateAuthProvider(ctx context.Context, provider *AuthProvider) error {
tx := db.WithContext(ctx).Save(provider)
if tx.Error != nil {
return fmt.Errorf("failed to update auth provider: %w", tx.Error)
}
return nil
}
// DeleteAuthProvider deletes an authentication provider by ID
func (db *DB) DeleteAuthProvider(ctx context.Context, id uint) error {
tx := db.WithContext(ctx).Delete(&AuthProvider{}, id)
if tx.Error != nil {
return fmt.Errorf("failed to delete auth provider: %w", tx.Error)
}
if tx.RowsAffected == 0 {
return fmt.Errorf("auth provider not found: %d", id)
}
return nil
}
// GetEnabledAuthProviders returns all enabled authentication providers
func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
var providers []AuthProvider
tx := db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&providers)
if tx.Error != nil {
return nil, fmt.Errorf("failed to get enabled auth providers: %w", tx.Error)
}
return providers, nil
}
// GetAuthProviderByType returns authentication providers of a specific type
func (db *DB) GetAuthProviderByType(ctx context.Context, providerType ProviderType) ([]AuthProvider, error) {
var providers []AuthProvider
tx := db.WithContext(ctx).Where("type = ?", providerType).Order("name asc").Find(&providers)
if tx.Error != nil {
return nil, fmt.Errorf("failed to get auth providers by type: %w", tx.Error)
}
return providers, nil
}
// GetExternalUserIdentitiesByProviderID returns all external user identities for a specific provider
func (db *DB) GetExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) ([]ExternalUserIdentity, error) {
var identities []ExternalUserIdentity
tx := db.WithContext(ctx).Where("provider_id = ?", providerID).Find(&identities)
if tx.Error != nil {
return nil, fmt.Errorf("failed to get external user identities: %w", tx.Error)
}
return identities, nil
}
// CountExternalUserIdentitiesByProviderID counts the number of external user identities for a specific provider
func (db *DB) CountExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) (int64, error) {
var count int64
tx := db.WithContext(ctx).Model(&ExternalUserIdentity{}).Where("provider_id = ?", providerID).Count(&count)
if tx.Error != nil {
return 0, fmt.Errorf("failed to count external user identities: %w", tx.Error)
}
return count, nil
}
// GetExternalUserIdentity gets an external user identity by provider ID and external ID
func (db *DB) GetExternalUserIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
var identity ExternalUserIdentity
tx := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
if tx.Error != nil {
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
return nil, nil // Not found, but not an error
}
return nil, fmt.Errorf("failed to get external user identity: %w", tx.Error)
}
return &identity, nil
}
// CreateExternalUserIdentity creates a new external user identity
func (db *DB) CreateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
tx := db.WithContext(ctx).Create(identity)
if tx.Error != nil {
return fmt.Errorf("failed to create external user identity: %w", tx.Error)
}
return nil
}
// UpdateExternalUserIdentity updates an existing external user identity
func (db *DB) UpdateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
tx := db.WithContext(ctx).Save(identity)
if tx.Error != nil {
return fmt.Errorf("failed to update external user identity: %w", tx.Error)
}
return nil
}
// DeleteExternalUserIdentity deletes an external user identity by ID
func (db *DB) DeleteExternalUserIdentity(ctx context.Context, id uint) error {
tx := db.WithContext(ctx).Delete(&ExternalUserIdentity{}, id)
if tx.Error != nil {
return fmt.Errorf("failed to delete external user identity: %w", tx.Error)
}
return nil
}
// UpdateAuthProviderLastUsed updates the last used timestamp and increments the successful logins counter
func (db *DB) UpdateAuthProviderLastUsed(ctx context.Context, providerID uint) error {
tx := db.WithContext(ctx).Model(&AuthProvider{}).
Where("id = ?", providerID).
Updates(map[string]interface{}{
"last_used": gorm.Expr("NOW()"),
"successful_logins": gorm.Expr("successful_logins + 1"),
})
if tx.Error != nil {
return fmt.Errorf("failed to update auth provider last used: %w", tx.Error)
}
return nil
}
+1505 -105
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
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)
}
defer func() {
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
fmt.Printf("Warning: failed to re-enable foreign key constraints: %v\n", err)
}
}()
// Create Users table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
last_password_change DATETIME,
failed_login_attempts INTEGER DEFAULT 0,
account_locked BOOLEAN DEFAULT FALSE,
lockout_until DATETIME,
theme VARCHAR(255) DEFAULT 'light',
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
return err
}
// Create PasswordHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create PasswordResetToken table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create TransferConfigs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS transfer_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
source_type VARCHAR(255) NOT NULL,
source_path TEXT NOT NULL,
source_host VARCHAR(255),
source_port INTEGER DEFAULT 22,
source_user VARCHAR(255),
source_key_file TEXT,
source_bucket VARCHAR(255),
source_region VARCHAR(255),
source_access_key VARCHAR(255),
source_endpoint VARCHAR(255),
source_share VARCHAR(255),
source_domain VARCHAR(255),
source_passive_mode BOOLEAN DEFAULT TRUE,
source_client_id VARCHAR(255),
source_drive_id VARCHAR(255),
source_team_drive VARCHAR(255),
source_read_only BOOLEAN,
source_start_year INTEGER,
source_include_archived BOOLEAN,
file_pattern VARCHAR(255) DEFAULT '*',
output_pattern TEXT,
destination_type VARCHAR(255) NOT NULL,
destination_path TEXT NOT NULL,
dest_host VARCHAR(255),
dest_port INTEGER DEFAULT 22,
dest_user VARCHAR(255),
dest_key_file TEXT,
dest_bucket VARCHAR(255),
dest_region VARCHAR(255),
dest_access_key VARCHAR(255),
dest_endpoint VARCHAR(255),
dest_share VARCHAR(255),
dest_domain VARCHAR(255),
dest_passive_mode BOOLEAN DEFAULT TRUE,
dest_client_id VARCHAR(255),
dest_drive_id VARCHAR(255),
dest_team_drive VARCHAR(255),
dest_read_only BOOLEAN,
dest_start_year INTEGER,
dest_include_archived BOOLEAN,
use_builtin_auth_source BOOLEAN DEFAULT TRUE,
use_builtin_auth_dest BOOLEAN DEFAULT TRUE,
google_drive_authenticated BOOLEAN,
archive_path TEXT,
archive_enabled BOOLEAN DEFAULT FALSE,
rclone_flags TEXT,
delete_after_transfer BOOLEAN DEFAULT FALSE,
skip_processed_files BOOLEAN DEFAULT TRUE,
max_concurrent_transfers INTEGER DEFAULT 4,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create Jobs table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255),
config_id INTEGER NOT NULL,
config_ids TEXT,
schedule VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME,
webhook_enabled BOOLEAN DEFAULT FALSE,
webhook_url TEXT,
webhook_secret TEXT,
webhook_headers TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
created_by INTEGER,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (config_id) REFERENCES transfer_configs(id),
FOREIGN KEY (created_by) REFERENCES users(id)
)`).Error; err != nil {
return err
}
// Create JobHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
start_time DATETIME NOT NULL,
end_time DATETIME,
status VARCHAR(255) NOT NULL,
bytes_transferred INTEGER,
files_transferred INTEGER,
error_message TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Create FileMetadata table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS file_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
file_name VARCHAR(255) NOT NULL,
original_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_hash VARCHAR(255),
creation_time DATETIME,
mod_time DATETIME,
processed_time DATETIME NOT NULL,
destination_path TEXT NOT NULL,
status VARCHAR(255) NOT NULL,
error_message TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (job_id) REFERENCES jobs(id)
)`).Error; err != nil {
return err
}
// Re-enable foreign key constraints and verify integrity
if err := tx.Exec("PRAGMA foreign_key_check").Error; err != nil {
return fmt.Errorf("foreign key integrity check failed: %v", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop tables in reverse order to handle foreign key constraints
tables := []string{
"file_metadata",
"job_histories",
"jobs",
"transfer_configs",
"password_reset_tokens",
"password_histories",
"users",
}
for _, table := range tables {
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", table)).Error; err != nil {
return err
}
}
return nil
},
}
}
@@ -0,0 +1,90 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
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
}
// Update destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'gdrive' WHERE destination_type = 'google_drive'`).Error
},
Rollback: func(tx *gorm.DB) error {
// Revert source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'google_drive' WHERE source_type = 'gdrive'`).Error; err != nil {
return err
}
// Revert destination_type
return tx.Exec(`UPDATE transfer_configs SET destination_type = 'google_drive' WHERE destination_type = 'gdrive'`).Error
},
}
}
+98
View File
@@ -0,0 +1,98 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// Add2FA creates a migration for adding Two-Factor Authentication fields
func Add2FA() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "003_add_2fa",
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)
}
// Add new columns for 2FA - one at a time for SQLite compatibility
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32)`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE users ADD COLUMN backup_codes TEXT`).Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Remove 2FA columns - one at a time for SQLite compatibility
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_secret`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_enabled`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE users DROP COLUMN backup_codes`).Error; err != nil {
return err
}
return nil
},
}
}
@@ -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,127 @@
package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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 {
// 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)
}
// 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,134 @@
package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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 {
// 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 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")
},
}
}
@@ -0,0 +1,122 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddUserNotifications adds the user_notifications table
func AddUserNotifications() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "008_add_user_notifications",
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 the user_notifications table
err := tx.Exec(`
CREATE TABLE IF NOT EXISTS user_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
link TEXT NOT NULL,
job_id INTEGER,
job_run_id INTEGER,
config_id INTEGER,
is_read BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).Error
if err != nil {
return err
}
// Create an index on user_id for faster lookups
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_user_id ON user_notifications(user_id)
`).Error
if err != nil {
return err
}
// Create an index on created_at for faster sorting
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_created_at ON user_notifications(created_at)
`).Error
if err != nil {
return err
}
// Create an index on is_read for faster filtering of unread notifications
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_is_read ON user_notifications(is_read)
`).Error
if err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS user_notifications`).Error
},
}
}
@@ -0,0 +1,349 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddRcloneTables adds tables for rclone commands and their flags
func AddRcloneTables() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "009_add_rclone_tables",
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 the rclone_commands table
err := tx.Exec(`
CREATE TABLE IF NOT EXISTS rclone_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
is_advanced BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`).Error
if err != nil {
return err
}
// Create an index on name for faster lookups
err = tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_rclone_commands_name ON rclone_commands(name)
`).Error
if err != nil {
return err
}
// Create an index on category for faster filtering
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_commands_category ON rclone_commands(category)
`).Error
if err != nil {
return err
}
// Create the rclone_command_flags table
err = tx.Exec(`
CREATE TABLE IF NOT EXISTS rclone_command_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id INTEGER NOT NULL,
name TEXT NOT NULL,
short_name TEXT,
description TEXT NOT NULL,
data_type TEXT NOT NULL,
is_required BOOLEAN NOT NULL DEFAULT 0,
default_value TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (command_id) REFERENCES rclone_commands(id) ON DELETE CASCADE
)
`).Error
if err != nil {
return err
}
// Create index on command_id for faster lookups
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_command_id ON rclone_command_flags(command_id)
`).Error
if err != nil {
return err
}
// Create index on flag name for faster searches
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_name ON rclone_command_flags(name)
`).Error
if err != nil {
return err
}
// Populate the tables with default rclone commands and flags
return populateRcloneTablesWithDefaults(tx)
},
Rollback: func(tx *gorm.DB) error {
// Drop the flags table first due to foreign key constraints
err := tx.Exec(`DROP TABLE IF EXISTS rclone_command_flags`).Error
if err != nil {
return err
}
// Then drop the commands table
return tx.Exec(`DROP TABLE IF EXISTS rclone_commands`).Error
},
}
}
// Helper function to populate rclone tables with default data
func populateRcloneTablesWithDefaults(tx *gorm.DB) error {
// Define base commands
commands := []struct {
Name string
Description string
Category string
IsAdvanced bool
}{
// Core commands
{"copy", "Copy files from source to dest, skipping already copied", "sync", false},
{"sync", "Make source and dest identical, modifying destination only", "sync", false},
{"bisync", "Bidirectional synchronization between two paths", "sync", false},
{"move", "Move files from source to dest", "sync", false},
{"delete", "Remove the contents of path", "sync", false},
{"purge", "Remove the path and all of its contents", "sync", false},
{"mkdir", "Make the path if it doesn't already exist", "sync", false},
{"rmdir", "Remove the path", "sync", false},
{"rmdirs", "Remove any empty directories under the path", "sync", false},
{"check", "Check if the files in the source and destination match", "sync", false},
{"ls", "List all the objects in the path with size and path", "listing", false},
{"lsd", "List all directories/containers/buckets in the path", "listing", false},
{"lsl", "List all the objects in the path with size, modification time and path", "listing", false},
{"lsf", "List the objects in the path (obey formatting parameters)", "listing", false},
{"lsjson", "List the objects in the path in JSON format", "listing", false},
{"md5sum", "Produce an md5sum file for all the objects in the path", "hash", false},
{"sha1sum", "Produce a sha1sum file for all the objects in the path", "hash", false},
{"size", "Return the total size and number of objects in remote:path", "info", false},
{"version", "Show the version number", "info", false},
{"cleanup", "Clean up the remote if possible", "maintenance", false},
{"dedupe", "Interactively find duplicate files and delete/rename them", "maintenance", false},
{"copyto", "Copy files from source to dest, skipping already copied", "sync", false},
{"moveto", "Move file or directory from source to dest", "sync", false},
{"listremotes", "List all the remotes in the config file", "config", false},
{"obscure", "Obscure password for use in the rclone.conf", "config", false},
{"cryptcheck", "Check the integrity of an encrypted remote", "crypto", false},
}
// Insert commands
for _, cmd := range commands {
err := tx.Exec(`
INSERT INTO rclone_commands
(name, description, category, is_advanced, created_at)
VALUES (?, ?, ?, ?, ?)
`, cmd.Name, cmd.Description, cmd.Category, cmd.IsAdvanced, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert command %s: %v", cmd.Name, err)
}
}
// Define all flags
allFlags := []struct {
CommandName string
Name string
ShortName string
Description string
DataType string
IsRequired bool
DefaultValue string
}{
// Global flags - apply to most commands
{"global", "transfers", "n", "Number of file transfers to run in parallel", "int", false, "4"},
{"global", "checkers", "", "Number of checkers to run in parallel", "int", false, "8"},
{"global", "log-level", "", "Log level DEBUG|INFO|NOTICE|ERROR", "string", false, "NOTICE"},
{"global", "stats", "", "Interval between logging stats, e.g 500ms, 60s, 5m", "string", false, "1m0s"},
{"global", "stats-file-name-length", "", "Max file name length in stats", "int", false, "45"},
{"global", "stats-one-line", "", "Make the stats fit on one line", "bool", false, "false"},
{"global", "progress", "p", "Show progress during transfer", "bool", false, "false"},
{"global", "verbose", "v", "Show verbose output", "bool", false, "false"},
{"global", "quiet", "q", "Print as little stuff as possible", "bool", false, "false"},
{"global", "retries", "", "Retry operations this many times if they fail", "int", false, "3"},
{"global", "retries-sleep", "", "Interval between retrying operations if they fail", "string", false, "0s"},
{"global", "timeout", "", "IO idle timeout", "string", false, "5m0s"},
{"global", "tpslimit", "", "Limit HTTP transactions per second", "float", false, "0"},
{"global", "tpslimit-burst", "", "Max burst of transactions for --tpslimit", "int", false, "1"},
{"global", "size-only", "", "Skip based on size only, not mod-time or checksum", "bool", false, "false"},
{"global", "ignore-checksum", "", "Skip post copy check of checksums", "bool", false, "false"},
{"global", "ignore-existing", "", "Skip all files that exist on destination", "bool", false, "false"},
{"global", "ignore-size", "", "Skip size checks to calculate if file changed", "bool", false, "false"},
{"global", "ignore-case-sync", "", "Ignore case when synchronizing", "bool", false, "false"},
{"global", "no-update-modtime", "", "Don't update destination mod-time if files identical", "bool", false, "false"},
{"global", "no-check-certificate", "", "Do not verify server SSL certificates", "bool", false, "false"},
{"global", "ask-password", "", "Allow prompt for password for encrypted config", "bool", false, "false"},
{"global", "dump", "", "List of items to dump from: headers,bodies,requests,responses,auth,filters,goroutines,openfiles", "string", false, ""},
{"global", "metadata", "M", "Preserve metadata when copying objects", "bool", false, "false"},
{"global", "metadata-set", "", "Add metadata key=value when uploading", "string", false, ""},
// copy command specific flags
{"copy", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"copy", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"copy", "cutoff-mode", "", "Mode to stop transfers when reaching the cutoff threshold", "string", false, "hard"},
{"copy", "max-transfer", "", "Maximum size of data to transfer", "string", false, "off"},
{"copy", "max-backlog", "", "Maximum size of upload or download backlog", "int", false, "10000"},
{"copy", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
{"copy", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
// sync command specific flags
{"sync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"sync", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"sync", "backup-dir", "", "Make backups into this directory", "string", false, ""},
{"sync", "suffix", "", "Suffix to add to changed files", "string", false, ""},
{"sync", "delete-before", "", "Delete before transferring", "bool", false, "false"},
{"sync", "delete-during", "", "Delete during transferring", "bool", false, "false"},
{"sync", "delete-after", "", "Delete after transferring", "bool", false, "false"},
{"sync", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
{"sync", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
// bisync command specific flags
{"bisync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"bisync", "resync", "", "Performs the resync run", "bool", false, "false"},
{"bisync", "check-access", "", "Ensure destination has write access", "bool", false, "true"},
{"bisync", "conflict-resolve", "", "Automatically resolve conflicts (newer|larger|older|smaller)", "string", false, ""},
{"bisync", "max-delete", "", "Safety check on maximum files to delete", "int", false, "10"},
// move command specific flags
{"move", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"move", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
{"move", "delete-empty-src-dirs", "", "Delete empty source dirs after move", "bool", false, "false"},
// ls/listing related flags
{"ls", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"ls", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"ls", "format", "", "Format for the output", "string", false, ""},
{"ls", "absolute", "", "Put a leading / in front of path names", "bool", false, "false"},
{"lsl", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsl", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsd", "max-depth", "", "Maximum depth to show in the listing", "int", false, ""},
{"lsd", "dir-sort", "", "Directory sorting (alphabetical|size|time)", "string", false, "alphabetical"},
{"lsf", "format", "", "Format for the output", "string", false, ""},
{"lsf", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsf", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsjson", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
{"lsjson", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
{"lsjson", "files-only", "", "Show only files, not directories", "bool", false, "false"},
{"lsjson", "encrypted", "", "Show the encrypted names", "bool", false, "false"},
{"lsjson", "stat", "", "Show status of objects", "bool", false, "false"},
// Other specialized command flags
{"cryptcheck", "one-way", "", "Check one way only, source files must exist on destination", "bool", false, "false"},
{"cleanup", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"dedupe", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
{"dedupe", "mode", "", "Dedupe mode interactive|skip|first|newest|oldest|largest|smallest|rename", "string", false, "interactive"},
}
// Insert flags with associated command IDs
for _, flag := range allFlags {
// For global flags, add them to all commands
if flag.CommandName == "global" {
// Get all command IDs
var commandIDs []int64
err := tx.Raw(`SELECT id FROM rclone_commands`).Scan(&commandIDs).Error
if err != nil {
return fmt.Errorf("failed to get all command IDs: %v", err)
}
// Add global flag to each command
for _, cmdID := range commandIDs {
err = tx.Exec(`
INSERT INTO rclone_command_flags
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, cmdID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert global flag %s for command ID %d: %v", flag.Name, cmdID, err)
}
}
} else {
// Add command-specific flag
var commandID int64
err := tx.Raw(`SELECT id FROM rclone_commands WHERE name = ?`, flag.CommandName).Scan(&commandID).Error
if err != nil {
return fmt.Errorf("failed to find command %s: %v", flag.CommandName, err)
}
err = tx.Exec(`
INSERT INTO rclone_command_flags
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, commandID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
if err != nil {
return fmt.Errorf("failed to insert flag %s for command %s: %v", flag.Name, flag.CommandName, err)
}
}
}
return nil
}
@@ -0,0 +1,104 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddRcloneCommandToConfig adds rclone command fields to the transfer_configs table
func AddRcloneCommandToConfig() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "010_add_rclone_command_to_config",
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)
}
// Add the command_id and command_flags columns to the transfer_configs table
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_id INTEGER DEFAULT NULL REFERENCES rclone_commands(id)`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flags TEXT DEFAULT NULL`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flag_values TEXT DEFAULT NULL`).Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Remove the columns in reverse order
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flags`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_id`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flag_values`).Error; err != nil {
return err
}
return nil
},
}
}
@@ -0,0 +1,134 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddAuthProviders adds tables for external authentication providers
func AddAuthProviders() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "011_add_auth_providers",
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, 0644); err != nil {
return fmt.Errorf("failed to write database backup: %v", err)
}
fmt.Printf("Created database backup at %s\n", backupFile)
}
// Create auth_providers table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS auth_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
description TEXT,
provider_url TEXT,
icon_url TEXT,
client_id VARCHAR(255),
client_secret VARCHAR(255),
redirect_url TEXT,
scopes TEXT,
attribute_mapping TEXT,
config TEXT,
successful_logins INTEGER DEFAULT 0,
last_used DATETIME,
created_at DATETIME,
updated_at DATETIME
)`).Error; err != nil {
return err
}
// Create external_user_identities table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS external_user_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider_id INTEGER NOT NULL,
provider_type VARCHAR(50) NOT NULL,
external_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
username VARCHAR(255),
display_name VARCHAR(255),
groups TEXT,
last_login DATETIME,
provider_data TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE
)`).Error; err != nil {
return err
}
// Create unique index on provider_id and external_id
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_external_user_identities_provider_external
ON external_user_identities(provider_id, external_id)`).Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Exec("DROP TABLE IF EXISTS external_user_identities").Error; err != nil {
return err
}
if err := tx.Exec("DROP TABLE IF EXISTS auth_providers").Error; err != nil {
return err
}
return nil
},
}
}
@@ -1,63 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddCloudStorageFields adds fields for WebDAV, NextCloud, OneDrive, and Google Drive
func AddCloudStorageFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_cloud_storage_fields",
Migrate: func(tx *gorm.DB) error {
// Add source fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_team_drive VARCHAR(255)").Error; err != nil {
return err
}
// Add destination fields
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_client_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_drive_id VARCHAR(255)").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_team_drive VARCHAR(255)").Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Drop source fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_team_drive").Error; err != nil {
return err
}
// Drop destination fields
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_client_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_drive_id").Error; err != nil {
return err
}
if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_team_drive").Error; err != nil {
return err
}
return nil
},
}
}
@@ -1,19 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddDeleteAfterTransferColumn adds the delete_after_transfer column to transfer_configs table
func AddDeleteAfterTransferColumn() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "add_delete_after_transfer_column",
Migrate: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN delete_after_transfer BOOLEAN NOT NULL DEFAULT false").Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN delete_after_transfer").Error
},
}
}
+20 -9
View File
@@ -5,13 +5,24 @@ import (
"gorm.io/gorm"
)
// InitMigrations initializes the migrations
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
// ... existing migrations
AddDeleteAfterTransferColumn(),
AddCloudStorageFields(),
}
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
AddUserNotifications(), // 008
AddRcloneTables(), // 009
AddRcloneCommandToConfig(), // 010
AddAuthProviders(), // 011
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
}
}
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"encoding/json"
"time"
"gorm.io/gorm"
)
// 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, 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:"-"`
EventTriggersJSON string `json:"-" 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 and EventTriggers to JSON strings for storage
func (n *NotificationService) BeforeSave(tx *gorm.DB) error {
configJSON, err := json.Marshal(n.Config)
if err != nil {
return err
}
n.ConfigJSON = string(configJSON)
eventsJSON, err := json.Marshal(n.EventTriggers)
if err != nil {
return err
}
n.EventTriggersJSON = string(eventsJSON)
return nil
}
// AfterFind converts JSON strings back to Config map and EventTriggers
func (n *NotificationService) AfterFind(tx *gorm.DB) error {
if n.ConfigJSON != "" {
if err := json.Unmarshal([]byte(n.ConfigJSON), &n.Config); err != nil {
return err
}
}
if n.EventTriggersJSON != "" {
if err := json.Unmarshal([]byte(n.EventTriggersJSON), &n.EventTriggers); err != nil {
return err
}
}
return nil
}
// GetNotificationServices returns notification services, filtered by enabled status if specified
func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService, error) {
var services []NotificationService
query := db.DB
if onlyEnabled {
query = query.Where("is_enabled = ?", true)
}
if err := query.Find(&services).Error; err != nil {
return nil, err
}
return services, nil
}
// GetNotificationService returns a notification service by ID
func (db *DB) GetNotificationService(id uint) (*NotificationService, error) {
var service NotificationService
if err := db.First(&service, id).Error; err != nil {
return nil, err
}
return &service, nil
}
// CreateNotificationService creates a new notification service
func (db *DB) CreateNotificationService(service *NotificationService) error {
return db.Create(service).Error
}
// UpdateNotificationService updates an existing notification service
func (db *DB) UpdateNotificationService(service *NotificationService) error {
return db.Save(service).Error
}
// DeleteNotificationService deletes a notification service by ID
func (db *DB) DeleteNotificationService(id uint) error {
return db.Delete(&NotificationService{}, id).Error
}
+299
View File
@@ -0,0 +1,299 @@
package db
import (
"database/sql/driver"
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
)
// Role represents a role in the system with associated permissions
type Role struct {
gorm.Model
Name string `gorm:"size:255;not null;unique"`
Description string `gorm:"type:text"`
Permissions Permissions `gorm:"type:text"`
Users []User `gorm:"many2many:user_roles;"`
}
// AssignToUser assigns this role to a user
func (r *Role) AssignToUser(tx *gorm.DB, userID uint, assignedByID uint) error {
// Check if the role is already assigned
var count int64
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("role is already assigned to user")
}
// Create the assignment
if err := tx.Exec("INSERT INTO user_roles (user_id, role_id, created_at, updated_at) VALUES (?, ?, ?, ?)",
userID, r.ID, time.Now(), time.Now()).Error; err != nil {
return err
}
// Create audit log
return tx.Create(&AuditLog{
Action: "assign_role",
EntityType: "role",
EntityID: r.ID,
UserID: assignedByID,
Details: map[string]interface{}{
"role_name": r.Name,
"user_id": userID,
},
Timestamp: time.Now(),
}).Error
}
// UnassignFromUser removes this role from a user
func (r *Role) UnassignFromUser(tx *gorm.DB, userID uint, unassignedByID uint) error {
// Check if the role is assigned
var count int64
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("role is not assigned to user")
}
// Remove the assignment
if err := tx.Exec("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userID, r.ID).Error; err != nil {
return err
}
// Create audit log
return tx.Create(&AuditLog{
Action: "unassign_role",
EntityType: "role",
EntityID: r.ID,
UserID: unassignedByID,
Details: map[string]interface{}{
"role_name": r.Name,
"user_id": userID,
},
Timestamp: time.Now(),
}).Error
}
// Validate performs validation on the role
func (r *Role) Validate() error {
// Name validation
if strings.TrimSpace(r.Name) == "" {
return errors.New("role name cannot be empty")
}
if len(r.Name) > 255 {
return errors.New("role name cannot exceed 255 characters")
}
if len(r.Description) > 1000 {
return errors.New("description cannot exceed 1000 characters")
}
// Permission validation
if r.Permissions == nil {
return errors.New("permissions cannot be nil")
}
// Validate each permission
validPerms := map[string]bool{
// User management
"users.view": true,
"users.create": true,
"users.edit": true,
"users.delete": true,
// Role management
"roles.view": true,
"roles.create": true,
"roles.edit": true,
"roles.delete": true,
// Config management
"configs.view": true,
"configs.create": true,
"configs.edit": true,
"configs.delete": true,
// Job management
"jobs.view": true,
"jobs.create": true,
"jobs.edit": true,
"jobs.delete": true,
"jobs.run": true,
// Audit logs
"audit.view": true,
"audit.export": true,
// System settings
"system.settings": true,
"system.backup": true,
"system.restore": true,
}
for _, perm := range r.Permissions {
if !validPerms[perm] {
return errors.New("invalid permission: " + perm)
}
}
return nil
}
// BeforeSave is a GORM hook that runs before saving the role
func (r *Role) BeforeSave(tx *gorm.DB) error {
return r.Validate()
}
// BeforeDelete is a GORM hook that runs before deleting the role
func (r *Role) BeforeDelete(tx *gorm.DB) error {
if r.IsSystemRole() {
return errors.New("cannot delete system role")
}
// Create audit log for deletion
return tx.Create(&AuditLog{
Action: "delete_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// AfterCreate is a GORM hook that runs after creating the role
func (r *Role) AfterCreate(tx *gorm.DB) error {
// Create audit log for creation
return tx.Create(&AuditLog{
Action: "create_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// AfterUpdate is a GORM hook that runs after updating the role
func (r *Role) AfterUpdate(tx *gorm.DB) error {
// Create audit log for update
return tx.Create(&AuditLog{
Action: "update_role",
EntityType: "role",
EntityID: r.ID,
Details: map[string]interface{}{
"role_name": r.Name,
"permissions": r.Permissions,
},
Timestamp: time.Now(),
}).Error
}
// Permissions is a custom type for storing permissions as a JSON array
type Permissions []string
// Scan implements the sql.Scanner interface
func (p *Permissions) Scan(value interface{}) error {
if value == nil {
*p = make(Permissions, 0)
return nil
}
var str string
switch v := value.(type) {
case []byte:
str = string(v)
case string:
str = v
default:
return errors.New("failed to scan Permissions: invalid type")
}
return json.Unmarshal([]byte(str), p)
}
// Value implements the driver.Valuer interface
func (p Permissions) Value() (driver.Value, error) {
if p == nil {
p = make(Permissions, 0)
}
bytes, err := json.Marshal(p)
if err != nil {
return nil, err
}
return string(bytes), nil
}
// HasPermission checks if the role has a specific permission
func (r *Role) HasPermission(permission string) bool {
for _, p := range r.Permissions {
if p == permission {
return true
}
}
return false
}
// AddPermission adds a permission to the role if it doesn't already exist
func (r *Role) AddPermission(permission string) {
if !r.HasPermission(permission) {
r.Permissions = append(r.Permissions, permission)
}
}
// RemovePermission removes a permission from the role
func (r *Role) RemovePermission(permission string) {
if r.Permissions == nil {
return
}
for i, p := range r.Permissions {
if p == permission {
r.Permissions = append(r.Permissions[:i], r.Permissions[i+1:]...)
return
}
}
}
// SetPermissions sets the role's permissions
func (r *Role) SetPermissions(permissions []string) {
r.Permissions = permissions
}
// GetPermissions returns the role's permissions
func (r *Role) GetPermissions() []string {
if r.Permissions == nil {
return make([]string, 0)
}
return r.Permissions
}
// IsSystemRole checks if this is a system-defined role that shouldn't be modified
func (r *Role) IsSystemRole() bool {
systemRoles := []string{"admin", "system", "superuser"}
for _, role := range systemRoles {
if strings.ToLower(r.Name) == role {
return true
}
}
return false
}
// AuditLog creates an audit log entry for role changes
func (r *Role) AuditLog(tx *gorm.DB, action string, userID uint) error {
return tx.Create(&AuditLog{
Action: action,
EntityType: "role",
EntityID: r.ID,
UserID: userID,
Details: map[string]interface{}{"name": r.Name, "permissions": r.Permissions},
}).Error
}
+147
View File
@@ -0,0 +1,147 @@
package db
import (
"fmt"
"time"
)
// NotificationType defines the type of notification
type NotificationType string
const (
NotificationJobStart NotificationType = "job_start"
NotificationJobComplete NotificationType = "job_complete"
NotificationJobFail NotificationType = "job_fail"
NotificationConfigUpdate NotificationType = "config_update"
NotificationSystemAlert NotificationType = "system_alert"
)
// UserNotification represents a notification shown to users in the UI
type UserNotification struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id" gorm:"index"`
Type NotificationType `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
JobID uint `json:"job_id,omitempty"`
JobRunID uint `json:"job_run_id,omitempty"`
ConfigID uint `json:"config_id,omitempty"`
IsRead bool `json:"is_read" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
}
// GetUserNotifications returns the latest notifications for a user
func (db *DB) GetUserNotifications(userID uint, limit int) ([]UserNotification, error) {
var notifications []UserNotification
result := db.Where("user_id = ?", userID).Order("created_at DESC").Limit(limit).Find(&notifications)
return notifications, result.Error
}
// GetUserNotificationCount returns the total count of notifications for a user
func (db *DB) GetUserNotificationCount(userID uint) (int64, error) {
var count int64
result := db.Model(&UserNotification{}).Where("user_id = ?", userID).Count(&count)
return count, result.Error
}
// GetPaginatedUserNotifications returns paginated notifications for a user
func (db *DB) GetPaginatedUserNotifications(userID uint, offset, limit int) ([]UserNotification, error) {
var notifications []UserNotification
result := db.Where("user_id = ?", userID).Order("created_at DESC").Offset(offset).Limit(limit).Find(&notifications)
return notifications, result.Error
}
// GetUnreadNotificationCount returns the count of unread notifications for a user
func (db *DB) GetUnreadNotificationCount(userID uint) (int64, error) {
var count int64
result := db.Model(&UserNotification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count)
return count, result.Error
}
// MarkNotificationAsRead marks a notification as read
func (db *DB) MarkNotificationAsRead(id uint) error {
return db.Model(&UserNotification{}).Where("id = ?", id).Update("is_read", true).Error
}
// MarkAllNotificationsAsRead marks all notifications for a user as read
func (db *DB) MarkAllNotificationsAsRead(userID uint) error {
return db.Model(&UserNotification{}).Where("user_id = ?", userID).Update("is_read", true).Error
}
// CreateJobNotification creates a notification for a job event
func (db *DB) CreateJobNotification(
userID uint,
jobID uint,
jobRunID uint,
notificationType NotificationType,
title string,
message string,
) error {
notification := UserNotification{
UserID: userID,
Type: notificationType,
Title: title,
Message: message,
JobID: jobID,
JobRunID: jobRunID,
Link: generateJobRunLink(jobRunID),
CreatedAt: time.Now(),
}
return db.Create(&notification).Error
}
// CreateConfigNotification creates a notification for a config update
func (db *DB) CreateConfigNotification(
userID uint,
configID uint,
title string,
message string,
) error {
notification := UserNotification{
UserID: userID,
Type: NotificationConfigUpdate,
Title: title,
Message: message,
ConfigID: configID,
Link: generateConfigLink(configID),
CreatedAt: time.Now(),
}
return db.Create(&notification).Error
}
// CreateSystemNotification creates a system-wide notification
func (db *DB) CreateSystemNotification(
title string,
message string,
) error {
// Get all active users
var users []User
if err := db.Where("active = ?", true).Find(&users).Error; err != nil {
return err
}
// Create a notification for each user
for _, user := range users {
notification := UserNotification{
UserID: user.ID,
Type: NotificationSystemAlert,
Title: title,
Message: message,
CreatedAt: time.Now(),
}
if err := db.Create(&notification).Error; err != nil {
return err
}
}
return nil
}
// Helper functions to generate links
func generateJobRunLink(jobRunID uint) string {
return "/job-runs/" + fmt.Sprintf("%d", jobRunID)
}
func generateConfigLink(configID uint) string {
return "/configs/" + fmt.Sprintf("%d", configID)
}
+188 -9
View File
@@ -26,12 +26,12 @@ func NewService(cfg *config.Config) *Service {
func (s *Service) SendPasswordResetEmail(toEmail, username, resetToken string) error {
if !s.Config.Email.Enabled {
// If email is not enabled, just log it (you can redirect to the default logging logic)
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
s.Config.BaseURL, resetToken)
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", s.Config.BaseURL, resetToken)
// Create email data for template
data := map[string]interface{}{
"Username": username,
@@ -124,7 +124,7 @@ func (s *Service) generatePasswordResetEmailHTML(data map[string]interface{}) (s
text-align: center;
}
.btn:hover {
background-color: #4338ca;
background-color:rgb(55, 113, 236);
}
.reset-link {
margin: 20px 0;
@@ -210,7 +210,7 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
if s.Config.Email.ReplyTo != "" {
headers["Reply-To"] = s.Config.Email.ReplyTo
}
@@ -224,7 +224,7 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
// Set up the SMTP server address
addr := fmt.Sprintf("%s:%d", s.Config.Email.Host, s.Config.Email.Port)
// Check if authentication is required
if s.Config.Email.RequireAuth {
// Use authenticated SMTP
@@ -237,7 +237,7 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
return fmt.Errorf("failed to connect to SMTP server: %v", err)
}
defer client.Close()
// Set up TLS if enabled
if s.Config.Email.EnableTLS {
if err := client.StartTLS(nil); err != nil {
@@ -252,7 +252,7 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
if err := client.Rcpt(toEmail); err != nil {
return fmt.Errorf("failed to set recipient: %v", err)
}
// Send the email body
w, err := client.Data()
if err != nil {
@@ -266,7 +266,186 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
if err != nil {
return fmt.Errorf("failed to close data writer: %v", err)
}
return client.Quit()
}
}
}
// SendTestEmail sends a test email to verify email configuration
func (s *Service) SendTestEmail(toEmail, subject, message string) error {
if !s.Config.Email.Enabled {
return fmt.Errorf("email service is disabled")
}
// Use default subject if not provided
if subject == "" {
subject = "Test Email from GoMFT"
}
// Use default message if not provided
if message == "" {
message = "This is a test email from GoMFT to verify the email configuration is working correctly."
}
// Create email data for template
data := map[string]interface{}{
"Subject": subject,
"Message": message,
"AppName": "GoMFT",
"Year": time.Now().Year(),
"SMTPServer": s.Config.Email.Host,
"SMTPPort": s.Config.Email.Port,
"FromEmail": s.Config.Email.FromEmail,
"CurrentTime": time.Now().Format(time.RFC1123Z),
}
// Generate email content
htmlContent, err := s.generateTestEmailHTML(data)
if err != nil {
return err
}
// Send the email
return s.sendEmail(toEmail, subject, htmlContent)
}
// generateTestEmailHTML generates the HTML content for test emails
func (s *Service) generateTestEmailHTML(data map[string]interface{}) (string, error) {
// HTML template for test email
tmpl, err := template.New("testEmail").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Subject}}</title>
<style>
/* Base styles */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-color: #f9fafb;
color: #374151;
line-height: 1.5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 1px solid #e5e7eb;
}
.logo {
width: 60px;
height: 60px;
margin: 0 auto 15px;
background-color: #2563eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.logo-icon {
font-size: 24px;
color: white;
font-weight: bold;
}
h1 {
color: #111827;
font-size: 24px;
margin: 0;
}
.content {
padding: 30px 20px;
}
p {
margin: 0 0 15px;
color: #4b5563;
}
.info-box {
margin: 20px 0;
padding: 15px;
background-color: #f3f4f6;
border-radius: 6px;
color: #4b5563;
}
.info-item {
display: flex;
margin-bottom: 8px;
}
.info-label {
font-weight: bold;
width: 140px;
}
.note {
font-size: 14px;
color: #6b7280;
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #e5e7eb;
}
.footer {
text-align: center;
font-size: 12px;
color: #9ca3af;
padding: 20px 0;
background-color: #f9fafb;
border-radius: 0 0 8px 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<div class="logo-icon">G</div>
</div>
<h1>{{.Subject}}</h1>
</div>
<div class="content">
<p>{{.Message}}</p>
<div class="info-box">
<div class="info-item">
<div class="info-label">SMTP Server:</div>
<div>{{.SMTPServer}}:{{.SMTPPort}}</div>
</div>
<div class="info-item">
<div class="info-label">From:</div>
<div>{{.FromEmail}}</div>
</div>
<div class="info-item">
<div class="info-label">Sent:</div>
<div>{{.CurrentTime}}</div>
</div>
</div>
<div class="note">
<p>This is a test email sent from the GoMFT admin interface. If you've received this email, your email configuration is working correctly.</p>
</div>
</div>
<div class="footer">
<p>&copy; {{.Year}} {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
`)
if err != nil {
return "", err
}
var result bytes.Buffer
if err := tmpl.Execute(&result, data); err != nil {
return "", err
}
return result.String(), nil
}
+35
View File
@@ -0,0 +1,35 @@
package email
import (
"fmt"
"github.com/starfleetcptn/gomft/internal/config"
)
// MockService implements the email Service for testing purposes
type MockService struct {
SendEmailCalls int
SendPasswordResetEmailCalls int
ReturnError error
}
// NewMockService creates a new mock email service
func NewMockService() *Service {
// Create minimal config
cfg := &config.Config{
Email: config.EmailConfig{
Enabled: false,
},
BaseURL: "http://localhost:8080",
}
return &Service{
Config: cfg,
}
}
// SendPasswordResetEmail mocks sending a password reset email
func (s *MockService) SendPasswordResetEmail(toEmail, username, resetToken string) error {
return fmt.Errorf("email service is disabled, reset link would be: %s/reset-password?token=%s",
"http://localhost:8080", resetToken)
}

Some files were not shown because too many files have changed in this diff Show More